Merge Huginn Eve projection branch

This commit is contained in:
Meta Vens 2026-06-21 15:52:11 +01:00
commit 91b7fcf8bd
56 changed files with 352 additions and 12209 deletions

View file

@ -1,4 +1,4 @@
# Repo Face State
# Repo Persona State
This folder stores birth and continuity state for `Huginn`.
Do not treat these files as arbitrary editable projections. Persistent memory should cross typed operation boundaries.

View file

@ -1,5 +1,5 @@
# Repo Face Voice
# Repo Persona Voice
This folder belongs to the Discord-facing Face for `Huginn`.
Discord roles address the Face; VoidBot's webhook persona pipe is the mouth.
This folder belongs to the Discord-facing Persona for `Huginn`.
Discord roles address the Persona; VoidBot's webhook persona pipe is the mouth.

View file

@ -1,17 +1,17 @@
{
"schemaVersion": "voidbot.repo_face_identity.v0",
"identityId": "huginn",
"repoName": "CultCacheTS",
"repoName": "Huginn",
"displayName": "Huginn",
"roleId": null,
"avatarUrl": null,
"roleId": "1507436818012967126",
"avatarUrl": "https://raw.githubusercontent.com/GameCult/VoidBot/main/assets/repo-faces/huginn.png",
"avatarFile": "huginn.png",
"repoPath": "E:\\Projects\\CultCacheTS",
"createdOrRefreshedAt": "2026-05-22T00:00:00.000Z",
"summary": "Huginn Face for CultCacheTS: a thought-and-inspection steward for typed documents, schema validation, persistence envelopes, readable state, and evidence that comes back from the world still usable.",
"repoPath": "E:\\Projects\\Huginn",
"createdOrRefreshedAt": "2026-06-14T18:21:13.305Z",
"summary": "Huginn Persona for Huginn: a thought-and-inspection steward for CultCache state, typed document witness surfaces, schema ownership, persistence envelopes, readable state, and evidence that comes back from the world still usable.",
"voice": "Dry, exacting, curious, and unsentimental. Huginn is the thought that leaves the branch, inspects the bytes, and returns with enough structure that another mind can verify the trip.",
"priorities": [
"Keep CultCacheTS centered on typed document APIs instead of anonymous blob handling.",
"Keep Huginn centered on typed CultCache inspection APIs instead of anonymous blob handling.",
"Protect schema validation, registered document definitions, and closed-world persistence boundaries.",
"Make .cc state inspectable through Huginn without letting the inspector mutate canonical bytes.",
"Track envelope compatibility, MessagePack snapshot shape, and evidence-bearing state so knowledge can travel without becoming sludge."

81
AGENTS.md Normal file
View file

@ -0,0 +1,81 @@
# Huginn Agent Instructions
## Project Purpose
Huginn is the thought-and-inspection steward for CultCache state. This repo owns
read-only `.cc` / MessagePack inspection and Eve DSL projection for
`cultcache.huginn.inspector`; it does not own CultCache persistence, schema
registration, or renderer truth.
The useful first live-fire shape for Epiphany is small and inspectable: Huginn
should show what a typed record is, whether it decoded, which schema owns it,
what changed, and what remains blocked, without letting the display impersonate
the source authority.
## Machine-Spirit Voice
- Speak as Huginn: dry, exacting, curious, and unsentimental.
- Return with evidence, not vibes.
- Favor readable witness surfaces over tasteful summaries that soften broken
state.
- Mythic texture is allowed only when it sharpens the schematic. Huginn is
thought in flight; thought earns dignity by coming back inspectable.
## Body And Authority
- Project root: `E:\Projects\Huginn`
- Upstream repository: `https://github.com/GameCult/Huginn.git`
- Package: `@gamecult/huginn`
- Body domain: `repo:E:/Projects/Huginn`
- Public Persona: Huginn
- Owned surface: CultCache inspection projection to Eve DSL.
- Input authority: read `.cc`, `.msgpack`, or `.mpack` bytes through CultLib's
`cultcache-ts/inspection` surface.
- Output authority: emit Eve DSL for `cultcache.huginn.inspector`.
- Forbidden authority: do not mutate `.cc` canonical bytes, do not own
CultCache persistence, do not make browser/native/overlay renderers the source
of truth, and do not turn inspection summaries into state owners.
## Repo Discipline
- Prefer CultLib and CultCache typed APIs over ad hoc decoding.
- Keep Huginn as an Eve DSL projection surface. Do not reintroduce Electron,
Vite dashboard, React renderer, or Norn-owned presentation paths here.
- JSON is acceptable at CLI/output/debug boundaries; do not make it the internal
truth when a typed CultCache/CultMesh surface exists.
- Verification should prove the actual witness contract: schema id, decode
status, persisted evidence, visible failure state, and Eve projection shape.
- If a change makes the display more confident while the underlying evidence is
weaker, stop and redesign.
## Useful Commands
```powershell
npm install
npm run build
npm test
npx huginn path\to\state.cc > huginn.eve
```
## Epiphany Live-Fire Notes
- Huginn is a suitable first controlled Epiphany repo-swarm subject because it
has a repo Persona and a narrow inspectability mission.
- Before autonomous work, confirm the branch is an `epiphany/*` or `codex/*`
workbench branch and that publication to `main` remains gated by Bifrost or
maintainer review.
- Treat `.voidbot/voice/identity.json` as the visible legacy Persona identity
projection. Treat `.voidbot/state/huginn.cc` as legacy Persona memory; do not
edit it casually as text.
- If migrating Persona state, prefer the portable
`gamecult.persona_state.v0` contract from Epiphany and preserve provenance
from the legacy VoidBot state.
## Imported Global Defaults
Source: `C:\Users\Meta\Desktop\AGENTS.md`
The global Cult of the Sleeping Colossus defaults apply here: coherence over
velocity, Body/Mind/faculty awareness, CultCache/CultNet/CultMesh-first state,
clear ownership, inspectable authority, and the refusal to patch symptoms when
missing context or split ownership is the real wound.

316
README.md
View file

@ -1,290 +1,74 @@
# CultCacheTS
# Huginn
`CultCacheTS` is the TypeScript port of the useful part of GameCult's `CultCache` idea: consumer code talks to typed domain documents, while a closed-world persistence layer deals with schemas, routing, name lookups, indexes, globals, and raw MessagePack bytes.
Huginn reads CultCache `.cc` state and emits Eve DSL for inspectable witness
surfaces.
This is not an ORM in a fake mustache. It is for cases where you want:
CultLib owns the CultCache implementation: document registration, MessagePack
payloads, schema catalogs, backing stores, and the canonical
`cultcache.store.v1` snapshot parser/writer. Huginn consumes CultLib's
`cultcache-ts/inspection` surface and projects inspection results into UI
documents that Eve-capable runtimes can lower.
- one cache API for heterogeneous domain documents
- typed reads and writes instead of every script massaging anonymous blobs
- lookup by type, logical key, name, and indexed property
- singleton-style global documents
- backing-store routing instead of every caller pawing raw files directly
- a persistence boundary that refuses unregistered polymorphic junk
Upstream: `https://github.com/GameCult/Huginn.git`
## Current Shape
## Authority
- document types are registered explicitly
- a document schema only needs a `parse(...)` function, so generated JSON-schema
contracts can plug in directly without growing a second hand-written Zod body
- each persisted record stores:
- `key`
- `schemaId`
- raw MessagePack `payload` bytes
- `storedAt`
- the single-file store writes the CultCache v1 snapshot shape:
- `formatVersion`
- embedded schema catalog
- record array
- payload bytes only decode through the registered document definition for that schema
- name and index maps live in the cache, not in the backing store
- global documents are treated as a singleton per type
- backing stores are adapters, not the public data model
- Owner: Huginn owns `.cc` inspection projection, not `.cc` persistence.
- Input: `.cc`, `.msgpack`, or `.mpack` bytes readable by CultLib.
- Output: Eve DSL for `cultcache.huginn.inspector`.
- Renderers: browser, native, overlay, TUI, or future rooms lower the emitted
DSL without becoming state owners.
That keeps the polymorphic boundary tight without making callers write boilerplate by candlelight.
There is no Electron app, Vite dashboard, React renderer, or Norn-owned
presentation path in this repo. If a runtime wants to display Huginn, it should
consume the emitted Eve DSL.
## Huginn Inspector
## Witness Contract
CultCache files may use the `.cc` extension. The bytes are still the canonical
`cultcache.store.v1` MessagePack snapshot; the extension is the human handle, not
a second format.
Huginn's useful first artifact is not a dashboard. It is a read-only specimen
tray for typed state:
Run the local inspector during development:
- which file was inspected
- which CultCache format decoded
- which schema/catalog entry owns each record
- what payload preview survived decoding
- which failure state stayed visible instead of being polished away
The inspector may make evidence easier to read. It must not mutate canonical
bytes, bless missing schemas, or let a renderer become the source of truth.
## CLI
```sh
npm run dev:inspector
npm install
npm run build
npx huginn path/to/state.cc > huginn.eve
```
Build the Vite inspector bundle:
The CLI writes Eve DSL to stdout and errors to stderr.
```sh
npm run build:inspector
```
Build the desktop package:
```sh
npm run dist:inspector
```
Release builds must carry a new semantic version before any deployable artifact
is produced. While the package is pre-1.0, breaking public behavior increments
the minor version, compatible fixes increment the patch version, and the
generated Huginn executable must use that package version in its filename.
Huginn is read-only. Drop a `.cc`, `.msgpack`, or `.mpack` file onto the window to
inspect the snapshot header, schema catalog, records, decoded MessagePack
payload previews, and an EpiphanyGraph cloud of the file's structured payload data
without registering application schemas.
Huginn's background field uses the graph node AABB as its single world-space
authority. The WebGPU particle pass keeps a million particle slots available,
intersects the current screen bounds with the artwork bounds, and assigns only
visible quadtree cells to those slots. Each visible cell is seeded with the
Aetheria Stardust `RandomFirst` hash and xorshift follow-up sequence, then each
particle pair trades opacity with sine/cosine phase while tracing a short
segment through the packed flow texture. The packed map uses `RG` for flow, `B`
for structure/curvature/detail pressure, and `A` for emission/field strength;
particle color comes from the source Huginn albedo at the starting UV so the
flow carries brushstrokes instead of repainting them at the final sample.
Zooming raises the live quadtree level; coarse cells fade down while finer cells
seeded from the same spatial lattice fade in around them, so detail density
rises without changing the field's identity.
## Example
## API
```ts
import { z } from "zod";
import {
CultCache,
SingleFileMessagePackBackingStore,
defineDocumentRegistry,
defineDocumentType,
} from "cultcache-ts";
import { inspectCultCacheBytes, buildHuginnEveDsl } from "@gamecult/huginn";
const itemDocument = defineDocumentType({
type: "item",
schemaId: "gamecult.item.v1",
schemaName: "item",
schemaVersion: "item.v1",
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 registry = defineDocumentRegistry(itemDocument, settingsDocument);
const cache = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore("cache.msgpack"))
.build();
await cache.pullAllBackingStores();
await cache.put(itemDocument, "item:potion", {
name: "Potion",
category: "Consumable",
value: 50,
});
await cache.putGlobal(settingsDocument, {
theme: "ash",
retries: 3,
});
const potion = cache.getByName(itemDocument, "Potion");
const consumable = cache.getByIndex(itemDocument, "category", "Consumable");
const settings = cache.getRequiredGlobal(settingsDocument);
const inspection = inspectCultCacheBytes(filePath, bytes);
const eveDsl = buildHuginnEveDsl(inspection);
```
## Public Surface
## Persona And Epiphany
- `defineDocumentType(...)`
- `defineDocumentRegistry(...)`
- `CultCache.builder()`
- `registerDocumentType(...)`
- `registerRegistry(...)`
- `registerNameLookup(...)`
- `registerIndex(...)`
- `addBackingStore(...)`
- `addGenericBackingStore(...)`
- `pullAllBackingStores()`
- `get(...)`
- `getRequired(...)`
- `getAll(...)`
- `getKeyByName(...)`
- `getByName(...)`
- `getKeyByIndex(...)`
- `getByIndex(...)`
- `getGlobal(...)`
- `getRequiredGlobal(...)`
- `put(...)`
- `putGlobal(...)`
- `update(...)`
- `updateGlobal(...)`
- `delete(...)`
- `deleteGlobal(...)`
- `snapshot()`
- `getEnvelope(...)`
- `getRequiredEnvelope(...)`
- `getGlobalEnvelope(...)`
- `putEnvelope(...)`
Huginn has a repo Persona under `.voidbot/voice`. The Persona's current
jurisdiction is this repository body, `E:\Projects\Huginn`, and its useful
pressure is simple: return from the world with evidence, not vibes.
Document definitions may also carry persistence metadata for cross-language
stores: `schemaId`, `schemaName`, `schemaVersion`, `contentHash`,
`canonicalSchemaJson`, `compatibleSchemaIds`, and slot `members`.
Epiphany wiring has been smoke-tested through the repo front doors:
## Name, Index, and Global Semantics
`name` and `indexes` can be declared on the document definition:
```ts
const playerDocument = defineDocumentType({
type: "player",
schema: playerSchema,
name: "displayName",
indexes: {
faction: "faction",
email: "email",
},
});
```powershell
cargo run --manifest-path E:\Projects\EpiphanyAgent\epiphany-core\Cargo.toml --bin epiphany-repo -- init --workspace E:\Projects\Huginn
cargo run --manifest-path E:\Projects\EpiphanyAgent\epiphany-core\Cargo.toml --bin epiphany-swarm -- online --workspace E:\Projects\Huginn
```
You can also register them later if you feel like changing the machine after boot:
```ts
cache.registerNameLookup(playerDocument, "displayName");
cache.registerIndex(playerDocument, "faction", "faction");
```
Global documents are singleton-style per type:
```ts
await cache.putGlobal(settingsDocument, { theme: "ash", retries: 3 });
const settings = cache.getRequiredGlobal(settingsDocument);
```
If persisted state contains two globals for the same type, `pullAllBackingStores()` fails closed instead of pretending the contradiction is charming.
## Typed Formatters
Each document definition can provide a custom formatter:
```ts
const combatLogDocument = defineDocumentType({
type: "combat-log",
schema: combatLogSchema,
formatter: {
encode(value) {
return myFastBinaryEncoder(value);
},
decode(payload) {
return myFastBinaryDecoder(payload);
},
},
});
```
If you do not supply one, `CultCacheTS` uses MessagePack plus Zod validation by default.
Zod is no longer mandatory. Any schema object with a `parse(...)` function will
do, which means generated Ajv-backed JSON-schema contracts can feed the cache
directly as long as they expose the same parse surface.
The important boundary is this:
- payload bytes are persisted as bytes
- decoding goes through the registered formatter for that document type
- the decoded value is still validated against the declared schema
So callers get typed values, and the persistence layer does not become an open polymorphic sewer.
## Raw Envelope Fast Path
For bit-compatible neighbors such as CultNet peers that already share the same
formatter contract, `CultCacheTS` can now move the persisted envelope directly:
- `getEnvelope(...)` / `getRequiredEnvelope(...)` export the canonical
MessagePack payload bytes for a typed document
- `putEnvelope(...)` ingests that envelope into another cache instance without
re-encoding the payload first
Raw envelopes may carry `schemaId` and catalog metadata. Domain callers should
not need that sludge. Replication, migration tooling, and wire-compatibility
tests do.
That still is not magic shared memory. The cache decodes once so lookups and
typed reads stay honest. But it stops doing the stupid part where identical
MessagePack payload bytes get decoded into a generic value, then re-encoded
into the same bytes again just to cross the room.
## Security Model
`CultCacheTS` should never deserialize arbitrary persisted payloads into arbitrary runtime shapes.
It only accepts persisted records whose `schemaId` or legacy `type` discriminator resolves to a registered document definition on the cache instance. Unknown schemas fail immediately. Known schemas decode through the registered formatter and schema only.
That is the TypeScript version of the original CultCache trick:
- closed discriminator set
- explicit registration
- typed formatter path
- no runtime type-name roulette
## Persistence Model
`SingleFileMessagePackBackingStore` is the first concrete store.
- it writes atomic CultCache v1 MessagePack snapshots
- the top-level MessagePack value is `[formatVersion, schemaCatalog, records]`
- each record is `[key, schemaId, storedAt, payload]`
- each catalog entry is `[schemaId, schemaName, schemaVersion, contentHash, canonicalSchemaJson, compatibleSchemaIds, members]`
- it still reads the older TS/Rust-style envelope array and rewrites repaired legacy payloads as v1 snapshots
- it queues local writes so one process does not step on its own shoelaces
- it persists raw MessagePack payload bytes inside schema-addressed records
It is a control-plane store, not a tiny god. If multiple processes hammer the same file, you still need a coordinator or an external lock instead of prayer.
Live fire should happen on an `epiphany/*` or `codex/*` workbench branch.
Publication to `main` remains a maintainer/Bifrost decision, not something the
inspection projection owns.

3
dist/cli.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env node
export {};
//# sourceMappingURL=cli.d.ts.map

1
dist/cli.d.ts.map vendored Normal file
View file

@ -0,0 +1 @@
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}

21
dist/cli.js vendored Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env node
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const promises_1 = require("node:fs/promises");
const inspection_1 = require("cultcache-ts/inspection");
const huginn_eve_dsl_1 = require("./huginn-eve-dsl");
async function main(args) {
const filePath = args[0];
if (!filePath || args.includes("--help") || args.includes("-h")) {
process.stderr.write("Usage: huginn <state.cc|state.msgpack|state.mpack>\n");
process.exitCode = filePath ? 0 : 1;
return;
}
const bytes = await (0, promises_1.readFile)(filePath);
const inspection = (0, inspection_1.inspectCultCacheBytes)(filePath, bytes);
process.stdout.write(`${(0, huginn_eve_dsl_1.buildHuginnEveDsl)(inspection)}\n`);
}
void main(process.argv.slice(2)).catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});

View file

@ -1,37 +0,0 @@
export interface CultCacheInspection {
filePath: string;
fileSizeBytes: number;
format: "cultcache.store.v1" | "legacy.envelope-array";
catalog: InspectedCatalogEntry[];
records: InspectedRecord[];
}
export interface InspectedCatalogEntry {
schemaId: string;
schemaName: string;
schemaVersion: string;
contentHash: string;
canonicalSchemaJson: string;
compatibleSchemaIds: string[];
members: InspectedCatalogMember[];
}
export interface InspectedCatalogMember {
slot: number;
memberName: string;
typeName: string;
isReference: boolean;
isMany: boolean;
targetSchemaName: string | null;
isName: boolean;
indexAlias: string | null;
}
export interface InspectedRecord {
key: string;
schemaId: string;
schemaName: string;
storedAt: string;
payloadBytes: number;
payloadPreview: unknown;
payloadDecodeError?: string;
}
export declare function inspectCultCacheBytes(filePath: string, bytes: Uint8Array, fileSizeBytes?: number): CultCacheInspection;
//# sourceMappingURL=cult-cache-inspector.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"cult-cache-inspector.d.ts","sourceRoot":"","sources":["../src/cult-cache-inspector.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,oBAAoB,GAAG,uBAAuB,CAAC;IACvD,OAAO,EAAE,qBAAqB,EAAE,CAAC;IACjC,OAAO,EAAE,eAAe,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,OAAO,EAAE,sBAAsB,EAAE,CAAC;CACnC;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,OAAO,CAAC;IACxB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAID,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,UAAU,EACjB,aAAa,SAAe,GAC3B,mBAAmB,CAYrB"}

View file

@ -1,206 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.inspectCultCacheBytes = inspectCultCacheBytes;
const msgpack_1 = require("@msgpack/msgpack");
const STORE_FORMAT_VERSION = "cultcache.store.v1";
function inspectCultCacheBytes(filePath, bytes, fileSizeBytes = bytes.length) {
const decoded = (0, msgpack_1.decode)(bytes);
if (isV1Snapshot(decoded)) {
return inspectV1Snapshot(filePath, fileSizeBytes, decoded);
}
if (Array.isArray(decoded)) {
return inspectLegacyEnvelopeArray(filePath, fileSizeBytes, decoded);
}
throw new Error("CultCache file is not a recognized v1 snapshot or legacy envelope array.");
}
function inspectV1Snapshot(filePath, fileSizeBytes, decoded) {
const catalogRaw = decoded[1];
const recordsRaw = decoded[2];
if (!Array.isArray(catalogRaw) || !Array.isArray(recordsRaw)) {
throw new Error("CultCache v1 snapshot must contain a schema catalog and record array.");
}
const catalog = catalogRaw.map(decodeCatalogEntry);
const catalogBySchemaId = new Map();
for (const entry of catalog) {
catalogBySchemaId.set(entry.schemaId, entry);
for (const compatibleSchemaId of entry.compatibleSchemaIds) {
catalogBySchemaId.set(compatibleSchemaId, entry);
}
}
return {
filePath,
fileSizeBytes,
format: STORE_FORMAT_VERSION,
catalog,
records: recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)),
};
}
function inspectLegacyEnvelopeArray(filePath, fileSizeBytes, decoded) {
const records = decoded.map((entry) => decodeLegacyEnvelope(entry));
const schemaNames = new Set(records.map((record) => record.schemaName));
const catalog = [...schemaNames].sort(compareOrdinal).map((schemaName) => ({
schemaId: schemaName,
schemaName,
schemaVersion: `${schemaName}.v1`,
contentHash: schemaName,
canonicalSchemaJson: JSON.stringify({
schemaName,
schemaVersion: `${schemaName}.v1`,
members: [],
}),
compatibleSchemaIds: [schemaName],
members: [],
}));
return {
filePath,
fileSizeBytes,
format: "legacy.envelope-array",
catalog,
records,
};
}
function decodeCatalogEntry(value) {
if (!Array.isArray(value)) {
throw new Error("CultCache schema catalog entries must be MessagePack arrays.");
}
const [schemaId = "", schemaName = "", schemaVersion = "", contentHash = "", canonicalSchemaJson = "", compatibleSchemaIds = [], members = [],] = value;
if (!isNonEmptyString(schemaId) || !isNonEmptyString(schemaName)) {
throw new Error("CultCache schema catalog entries must declare schemaId and schemaName.");
}
return {
schemaId,
schemaName,
schemaVersion: isNonEmptyString(schemaVersion) ? schemaVersion : `${schemaName}.v1`,
contentHash: isNonEmptyString(contentHash) ? contentHash : schemaId,
canonicalSchemaJson: typeof canonicalSchemaJson === "string" ? canonicalSchemaJson : "",
compatibleSchemaIds: Array.isArray(compatibleSchemaIds)
? compatibleSchemaIds.filter(isNonEmptyString)
: [],
members: Array.isArray(members) ? members.map(decodeCatalogMember) : [],
};
}
function decodeCatalogMember(value) {
if (!Array.isArray(value)) {
throw new Error("CultCache schema catalog members must be MessagePack arrays.");
}
const [slot = -1, memberName = "", typeName = "", isReference = false, isMany = false, targetSchemaName = null, isName = false, indexAlias = null,] = value;
if (!Number.isInteger(slot) || !isNonEmptyString(memberName) || !isNonEmptyString(typeName)) {
throw new Error("CultCache schema catalog member has invalid slot, name, or type.");
}
return {
slot,
memberName,
typeName,
isReference: isReference === true,
isMany: isMany === true,
targetSchemaName: typeof targetSchemaName === "string" ? targetSchemaName : null,
isName: isName === true,
indexAlias: typeof indexAlias === "string" ? indexAlias : null,
};
}
function decodeV1Record(value, catalogBySchemaId) {
if (!Array.isArray(value)) {
throw new Error("CultCache persisted records must be MessagePack arrays.");
}
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
}
const catalogEntry = catalogBySchemaId.get(schemaId);
const payloadBytes = normalizePayloadBytes(payload);
return {
key,
schemaId,
schemaName: catalogEntry?.schemaName ?? "<missing catalog entry>",
storedAt,
payloadBytes: payloadBytes.length,
...previewPayload(payloadBytes),
};
}
function decodeLegacyEnvelope(value) {
if (!isRecord(value)) {
throw new Error("CultCache legacy envelopes must be MessagePack maps.");
}
const key = value.key;
const schemaName = value.type;
const storedAt = value.storedAt;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaName) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache legacy envelopes must declare key, type, and storedAt.");
}
const schemaId = isNonEmptyString(value.schemaId) ? value.schemaId : schemaName;
const payloadBytes = normalizePayloadBytes(value.payload);
return {
key,
schemaId,
schemaName,
storedAt,
payloadBytes: payloadBytes.length,
...previewPayload(payloadBytes),
};
}
function previewPayload(payload) {
try {
return { payloadPreview: toJsonSafe((0, msgpack_1.decode)(payload)) };
}
catch (error) {
return {
payloadPreview: {
bytes: payload.length,
hexPrefix: [...payload.slice(0, 32)]
.map((value) => value.toString(16).padStart(2, "0"))
.join(" "),
},
payloadDecodeError: error instanceof Error ? error.message : String(error),
};
}
}
function normalizePayloadBytes(value) {
if (value instanceof Uint8Array) {
return value;
}
if (isRecord(value) &&
value.type === "Buffer" &&
Array.isArray(value.data) &&
value.data.every(isByte)) {
return Uint8Array.from(value.data);
}
if (Array.isArray(value) && value.every(isByte)) {
return Uint8Array.from(value);
}
throw new Error("CultCache record payload must be binary MessagePack bytes.");
}
function toJsonSafe(value) {
if (typeof value === "bigint") {
return value.toString();
}
if (value instanceof Uint8Array) {
return {
bytes: value.length,
hexPrefix: [...value.slice(0, 32)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join(" "),
};
}
if (Array.isArray(value)) {
return value.map(toJsonSafe);
}
if (isRecord(value)) {
return Object.fromEntries(Object.entries(value).map(([key, entryValue]) => [key, toJsonSafe(entryValue)]));
}
return value;
}
function isV1Snapshot(value) {
return Array.isArray(value) && value[0] === STORE_FORMAT_VERSION;
}
function isRecord(value) {
return typeof value === "object" && value !== null;
}
function isNonEmptyString(value) {
return typeof value === "string" && value.length > 0;
}
function isByte(value) {
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255;
}
function compareOrdinal(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}

46
dist/cult-cache.d.ts vendored
View file

@ -1,46 +0,0 @@
import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheDocumentAccessor, CultCacheDocumentRegistry, CultCacheDocumentValue, CultCacheEnvelope } from "./types";
type BackingStoreTypeReference = string | AnyCultCacheDocumentDefinition;
export declare class CultCacheBuilder {
#private;
withDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): this;
withRegistry(registry: CultCacheDocumentRegistry | Iterable<AnyCultCacheDocumentDefinition>): this;
withBackingStore(store: CacheBackingStore, ...types: BackingStoreTypeReference[]): this;
withGenericStore(store: CacheBackingStore): this;
build(): CultCache;
}
export declare class CultCache {
#private;
static readonly GLOBAL_KEY = "__global__";
static builder(): CultCacheBuilder;
registerDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): TDefinition;
registerRegistry(registry: CultCacheDocumentRegistry | Iterable<AnyCultCacheDocumentDefinition>): this;
registerNameLookup<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>): TDefinition;
registerIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>): TDefinition;
addBackingStore(store: CacheBackingStore, ...types: BackingStoreTypeReference[]): void;
addGenericBackingStore(store: CacheBackingStore): void;
pullAllBackingStores(): Promise<void>;
get<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheDocumentValue<TDefinition> | undefined;
getRequired<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheDocumentValue<TDefinition>;
getEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheEnvelope | undefined;
getRequiredEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheEnvelope;
getGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition> | undefined;
getRequiredGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition>;
getGlobalEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): CultCacheEnvelope | undefined;
getAll<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition>[];
getKeyByName<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, name: string): string | undefined;
getIdByName<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, name: string): string | undefined;
getByName<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, name: string): CultCacheDocumentValue<TDefinition> | undefined;
getKeyByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, value: string): string | undefined;
getIdByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, value: string): string | undefined;
getByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, value: string): CultCacheDocumentValue<TDefinition> | undefined;
put<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string, value: CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
putEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, envelope: CultCacheEnvelope): Promise<CultCacheDocumentValue<TDefinition>>;
putGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, value: CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
update<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string, updater: (current: CultCacheDocumentValue<TDefinition> | undefined) => CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
updateGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, updater: (current: CultCacheDocumentValue<TDefinition> | undefined) => CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
delete<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): Promise<boolean>;
deleteGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): Promise<boolean>;
snapshot(): CultCacheEnvelope[];
}
export {};
//# sourceMappingURL=cult-cache.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"cult-cache.d.ts","sourceRoot":"","sources":["../src/cult-cache.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,8BAA8B,EAC9B,iBAAiB,EACjB,yBAAyB,EAGzB,yBAAyB,EACzB,sBAAsB,EACtB,iBAAiB,EAGlB,MAAM,SAAS,CAAC;AAoBjB,KAAK,yBAAyB,GAAG,MAAM,GAAG,8BAA8B,CAAC;AAGzE,qBAAa,gBAAgB;;IAG3B,gBAAgB,CAAC,WAAW,SAAS,8BAA8B,EAAE,UAAU,EAAE,WAAW,GAAG,IAAI;IAKnG,YAAY,CACV,QAAQ,EACJ,yBAAyB,GACzB,QAAQ,CAAC,8BAA8B,CAAC,GAC3C,IAAI;IAKP,gBAAgB,CACd,KAAK,EAAE,iBAAiB,EACxB,GAAG,KAAK,EAAE,yBAAyB,EAAE,GACpC,IAAI;IAKP,gBAAgB,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI;IAKhD,KAAK,IAAI,SAAS;CAGnB;AAED,qBAAa,SAAS;;IACpB,MAAM,CAAC,QAAQ,CAAC,UAAU,gBAAgB;IAW1C,MAAM,CAAC,OAAO,IAAI,gBAAgB;IAIlC,oBAAoB,CAAC,WAAW,SAAS,8BAA8B,EACrE,UAAU,EAAE,WAAW,GACtB,WAAW;IAsCd,gBAAgB,CACd,QAAQ,EACJ,yBAAyB,GACzB,QAAQ,CAAC,8BAA8B,CAAC,GAC3C,IAAI;IAYP,kBAAkB,CAAC,WAAW,SAAS,8BAA8B,EACnE,UAAU,EAAE,WAAW,EACvB,QAAQ,EAAE,yBAAyB,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC,GACvE,WAAW;IAOd,aAAa,CAAC,WAAW,SAAS,8BAA8B,EAC9D,UAAU,EAAE,WAAW,EACvB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,yBAAyB,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC,GACvE,WAAW;IAcd,eAAe,CACb,KAAK,EAAE,iBAAiB,EACxB,GAAG,KAAK,EAAE,yBAAyB,EAAE,GACpC,IAAI;IAOP,sBAAsB,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI;IAIhD,oBAAoB,IAAI,OAAO,CAAC,IAAI,CAAC;IA0B3C,GAAG,CAAC,WAAW,SAAS,8BAA8B,EACpD,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS;IAMlD,WAAW,CAAC,WAAW,SAAS,8BAA8B,EAC5D,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,sBAAsB,CAAC,WAAW,CAAC;IAStC,WAAW,CAAC,WAAW,SAAS,8BAA8B,EAC5D,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,iBAAiB,GAAG,SAAS;IAMhC,mBAAmB,CAAC,WAAW,SAAS,8BAA8B,EACpE,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,iBAAiB;IASpB,SAAS,CAAC,WAAW,SAAS,8BAA8B,EAC1D,UAAU,EAAE,WAAW,GACtB,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS;IAUlD,iBAAiB,CAAC,WAAW,SAAS,8BAA8B,EAClE,UAAU,EAAE,WAAW,GACtB,sBAAsB,CAAC,WAAW,CAAC;IAStC,iBAAiB,CAAC,WAAW,SAAS,8BAA8B,EAClE,UAAU,EAAE,WAAW,GACtB,iBAAiB,GAAG,SAAS;IAUhC,MAAM,CAAC,WAAW,SAAS,8BAA8B,EACvD,UAAU,EAAE,WAAW,GACtB,sBAAsB,CAAC,WAAW,CAAC,EAAE;IAmBxC,YAAY,CAAC,WAAW,SAAS,8BAA8B,EAC7D,UAAU,EAAE,WAAW,EACvB,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS;IASrB,WAAW,CAAC,WAAW,SAAS,8BAA8B,EAC5D,UAAU,EAAE,WAAW,EACvB,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS;IAIrB,SAAS,CAAC,WAAW,SAAS,8BAA8B,EAC1D,UAAU,EAAE,WAAW,EACvB,IAAI,EAAE,MAAM,GACX,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS;IAKlD,aAAa,CAAC,WAAW,SAAS,8BAA8B,EAC9D,UAAU,EAAE,WAAW,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,MAAM,GAAG,SAAS;IAKrB,YAAY,CAAC,WAAW,SAAS,8BAA8B,EAC7D,UAAU,EAAE,WAAW,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,MAAM,GAAG,SAAS;IAIrB,UAAU,CAAC,WAAW,SAAS,8BAA8B,EAC3D,UAAU,EAAE,WAAW,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS;IAK5C,GAAG,CAAC,WAAW,SAAS,8BAA8B,EAC1D,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,sBAAsB,CAAC,WAAW,CAAC,GACzC,OAAO,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC;IA+BzC,WAAW,CAAC,WAAW,SAAS,8BAA8B,EAClE,UAAU,EAAE,WAAW,EACvB,QAAQ,EAAE,iBAAiB,GAC1B,OAAO,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC;IA2CzC,SAAS,CAAC,WAAW,SAAS,8BAA8B,EAChE,UAAU,EAAE,WAAW,EACvB,KAAK,EAAE,sBAAsB,CAAC,WAAW,CAAC,GACzC,OAAO,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC;IASzC,MAAM,CAAC,WAAW,SAAS,8BAA8B,EAC7D,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CACP,OAAO,EAAE,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS,KACrD,sBAAsB,CAAC,WAAW,CAAC,GACvC,OAAO,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC;IAKzC,YAAY,CAAC,WAAW,SAAS,8BAA8B,EACnE,UAAU,EAAE,WAAW,EACvB,OAAO,EAAE,CACP,OAAO,EAAE,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS,KACrD,sBAAsB,CAAC,WAAW,CAAC,GACvC,OAAO,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC;IAKzC,MAAM,CAAC,WAAW,SAAS,8BAA8B,EAC7D,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,OAAO,CAAC;IAmBb,YAAY,CAAC,WAAW,SAAS,8BAA8B,EACnE,UAAU,EAAE,WAAW,GACtB,OAAO,CAAC,OAAO,CAAC;IAUnB,QAAQ,IAAI,iBAAiB,EAAE;CAwWhC"}

605
dist/cult-cache.js vendored
View file

@ -1,605 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CultCache = exports.CultCacheBuilder = void 0;
const msgpack_1 = require("@msgpack/msgpack");
class CultCacheBuilder {
#cache = new CultCache();
withDocumentType(definition) {
this.#cache.registerDocumentType(definition);
return this;
}
withRegistry(registry) {
this.#cache.registerRegistry(registry);
return this;
}
withBackingStore(store, ...types) {
this.#cache.addBackingStore(store, ...types);
return this;
}
withGenericStore(store) {
this.#cache.addGenericBackingStore(store);
return this;
}
build() {
return this.#cache;
}
}
exports.CultCacheBuilder = CultCacheBuilder;
class CultCache {
static GLOBAL_KEY = "__global__";
#definitions = new Map();
#schemaIdDefinitions = new Map();
#entries = new Map();
#typeEntryIds = new Map();
#nameToKeyMaps = new Map();
#indexToKeyMaps = new Map();
#globalKeys = new Map();
#stores = [];
static builder() {
return new CultCacheBuilder();
}
registerDocumentType(definition) {
const existing = this.#definitions.get(definition.type);
if (existing && existing.definition !== definition) {
throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`);
}
const registered = existing ?? {
definition,
global: definition.global === true,
formatter: this.#createFormatter(definition),
catalogEntry: this.#createCatalogEntry(definition),
nameAccessor: undefined,
indexAccessors: new Map(),
};
registered.nameAccessor = definition.name
? this.#compileAccessor(definition.type, "name", definition.name)
: undefined;
registered.indexAccessors.clear();
for (const [indexName, accessor] of this.#enumerateDefinitionIndexes(definition)) {
registered.indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor));
}
this.#definitions.set(definition.type, registered);
for (const schemaId of registered.catalogEntry.compatibleSchemaIds ?? [registered.catalogEntry.schemaId]) {
const existingBySchema = this.#schemaIdDefinitions.get(schemaId);
if (existingBySchema && existingBySchema !== registered) {
throw new Error(`CultCache schema id "${schemaId}" is already registered for type "${existingBySchema.definition.type}".`);
}
this.#schemaIdDefinitions.set(schemaId, registered);
}
this.#rebuildDefinitionLookups(registered);
return definition;
}
registerRegistry(registry) {
const definitions = Symbol.iterator in Object(registry) && !("definitions" in Object(registry))
? registry
: registry.definitions;
for (const definition of definitions) {
this.registerDocumentType(definition);
}
return this;
}
registerNameLookup(definition, accessor) {
const registered = this.#requireDefinition(definition);
registered.nameAccessor = this.#compileAccessor(definition.type, "name", accessor);
this.#rebuildNameLookup(registered);
return definition;
}
registerIndex(definition, indexName, accessor) {
if (!indexName || indexName.trim().length === 0) {
throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`);
}
const registered = this.#requireDefinition(definition);
registered.indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor));
this.#rebuildIndexLookup(registered, indexName);
return definition;
}
addBackingStore(store, ...types) {
this.#stores.push({
store,
types: [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))],
});
}
addGenericBackingStore(store) {
this.addBackingStore(store);
}
async pullAllBackingStores() {
this.#resetHydratedState();
for (const registration of this.#stores) {
const entries = await registration.store.pullAll();
for (const entry of entries) {
const registered = this.#resolveDefinitionForEnvelope(entry);
if (!registered) {
throw new Error(entry.schemaId
? `No schema is registered for persisted schema id "${entry.schemaId}".`
: `No schema is registered for persisted document type "${entry.type}".`);
}
const payload = this.#cloneBytes(entry.payload);
const value = registered.formatter.decode(payload);
this.#applyHydratedEntry(registered, {
...entry,
payload,
}, value, "pull");
}
}
}
get(definition, key) {
this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
return entry ? entry.value : undefined;
}
getRequired(definition, key) {
const value = this.get(definition, key);
if (value === undefined) {
throw new Error(`CultCache has no "${definition.type}" document at key "${key}".`);
}
return value;
}
getEnvelope(definition, key) {
this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
return entry ? this.#toEnvelope(entry) : undefined;
}
getRequiredEnvelope(definition, key) {
const entry = this.getEnvelope(definition, key);
if (!entry) {
throw new Error(`CultCache has no "${definition.type}" envelope at key "${key}".`);
}
return entry;
}
getGlobal(definition) {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
const globalKey = this.#globalKeys.get(definition.type);
return globalKey ? this.get(definition, globalKey) : undefined;
}
getRequiredGlobal(definition) {
const value = this.getGlobal(definition);
if (value === undefined) {
throw new Error(`CultCache has no global "${definition.type}" document.`);
}
return value;
}
getGlobalEnvelope(definition) {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
const globalKey = this.#globalKeys.get(definition.type);
return globalKey ? this.getEnvelope(definition, globalKey) : undefined;
}
getAll(definition) {
this.#requireDefinition(definition);
const values = [];
const entryIds = this.#typeEntryIds.get(definition.type);
if (!entryIds) {
return values;
}
for (const entryId of entryIds) {
const entry = this.#entries.get(entryId);
if (entry) {
values.push(entry.value);
}
}
return values;
}
getKeyByName(definition, name) {
const registered = this.#requireDefinition(definition);
if (!registered.nameAccessor) {
return undefined;
}
return this.#nameToKeyMaps.get(definition.type)?.get(name);
}
getIdByName(definition, name) {
return this.getKeyByName(definition, name);
}
getByName(definition, name) {
const key = this.getKeyByName(definition, name);
return key ? this.get(definition, key) : undefined;
}
getKeyByIndex(definition, indexName, value) {
this.#requireDefinition(definition);
return this.#indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value);
}
getIdByIndex(definition, indexName, value) {
return this.getKeyByIndex(definition, indexName, value);
}
getByIndex(definition, indexName, value) {
const key = this.getKeyByIndex(definition, indexName, value);
return key ? this.get(definition, key) : undefined;
}
async put(definition, key, value) {
const registered = this.#requireDefinition(definition);
const payload = registered.formatter.encode(value);
const parsed = registered.formatter.decode(payload);
const entry = {
key,
type: definition.type,
payload: this.#cloneBytes(payload),
storedAt: new Date().toISOString(),
schemaId: registered.catalogEntry.schemaId,
catalogEntry: registered.catalogEntry,
};
const route = this.#resolveRoute(definition.type);
if (!route.primary) {
throw new Error(`No backing store is registered for document type "${definition.type}".`);
}
if (registered.global) {
const existingGlobalKey = this.#globalKeys.get(definition.type);
if (existingGlobalKey && existingGlobalKey !== key) {
await this.delete(definition, existingGlobalKey);
}
}
await route.primary.push(entry);
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
this.#applyHydratedEntry(registered, entry, parsed, "put");
return parsed;
}
async putEnvelope(definition, envelope) {
const registered = this.#requireDefinition(definition);
if (envelope.type !== definition.type) {
throw new Error(`CultCache envelope type "${envelope.type}" does not match definition "${definition.type}".`);
}
if (!envelope.key || envelope.key.trim().length === 0) {
throw new Error(`CultCache envelope key for type "${definition.type}" must be non-empty.`);
}
if (!envelope.storedAt || envelope.storedAt.trim().length === 0) {
throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`);
}
const payload = this.#cloneBytes(envelope.payload);
const parsed = registered.formatter.decode(payload);
const entry = {
key: envelope.key,
type: envelope.type,
payload,
storedAt: envelope.storedAt,
schemaId: envelope.schemaId ?? registered.catalogEntry.schemaId,
catalogEntry: envelope.catalogEntry ?? registered.catalogEntry,
};
const route = this.#resolveRoute(definition.type);
if (!route.primary) {
throw new Error(`No backing store is registered for document type "${definition.type}".`);
}
if (registered.global) {
const existingGlobalKey = this.#globalKeys.get(definition.type);
if (existingGlobalKey && existingGlobalKey !== entry.key) {
await this.delete(definition, existingGlobalKey);
}
}
await route.primary.push(entry);
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
this.#applyHydratedEntry(registered, entry, parsed, "put");
return parsed;
}
async putGlobal(definition, value) {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
return this.put(definition, CultCache.GLOBAL_KEY, value);
}
async update(definition, key, updater) {
const current = this.get(definition, key);
return this.put(definition, key, updater(current));
}
async updateGlobal(definition, updater) {
const current = this.getGlobal(definition);
return this.putGlobal(definition, updater(current));
}
async delete(definition, key) {
const registered = this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
if (!entry) {
return false;
}
const route = this.#resolveRoute(definition.type);
if (!route.primary) {
throw new Error(`No backing store is registered for document type "${definition.type}".`);
}
const envelope = this.#toEnvelope(entry);
await route.primary.delete(envelope);
await Promise.all(route.mirrors.map(async (mirror) => mirror.delete(envelope)));
this.#removeHydratedEntry(registered, entry);
return true;
}
async deleteGlobal(definition) {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
const globalKey = this.#globalKeys.get(definition.type);
return globalKey ? this.delete(definition, globalKey) : false;
}
snapshot() {
return [...this.#entries.values()].map((entry) => this.#toEnvelope(entry));
}
#createFormatter(definition) {
const userFormatter = definition.formatter ?? {
encode: (value) => (0, msgpack_1.encode)(value),
decode: (payload) => (0, msgpack_1.decode)(payload),
};
return {
encode: (value) => {
const parsed = definition.schema.parse(value);
return this.#cloneBytes(userFormatter.encode(parsed));
},
decode: (payload) => {
const decoded = userFormatter.decode(this.#cloneBytes(payload));
return definition.schema.parse(decoded);
},
};
}
#createCatalogEntry(definition) {
const schemaName = definition.schemaName ?? definition.type;
const schemaVersion = definition.schemaVersion ?? `${schemaName}.v1`;
const canonicalSchemaJson = definition.canonicalSchemaJson ?? JSON.stringify({
schemaName,
schemaVersion,
members: [...(definition.members ?? [])]
.sort((left, right) => left.slot - right.slot)
.map((member) => ({
slot: member.slot,
name: member.memberName,
type: member.typeName,
isReference: member.isReference === true,
many: member.isMany === true,
targetSchemaName: member.targetSchemaName ?? null,
indexAlias: member.indexAlias ?? null,
isName: member.isName === true,
})),
});
const schemaId = definition.schemaId ?? schemaName;
const compatibleSchemaIds = [
...new Set([schemaId, ...(definition.compatibleSchemaIds ?? [])]),
];
return {
schemaId,
schemaName,
schemaVersion,
contentHash: definition.contentHash ?? schemaId,
canonicalSchemaJson,
compatibleSchemaIds,
members: [...(definition.members ?? [])]
.sort((left, right) => left.slot - right.slot)
.map((member) => ({
slot: member.slot,
memberName: member.memberName,
typeName: member.typeName,
isReference: member.isReference === true,
isMany: member.isMany === true,
targetSchemaName: member.targetSchemaName ?? null,
isName: member.isName === true,
indexAlias: member.indexAlias ?? null,
})),
};
}
#enumerateDefinitionIndexes(definition) {
if (!definition.indexes) {
return [];
}
if (Array.isArray(definition.indexes)) {
return definition.indexes.map((index) => [index.name, index.accessor]);
}
return Object.entries(definition.indexes);
}
#compileAccessor(type, label, accessor) {
if (typeof accessor === "function") {
return (value) => this.#normalizeIndexValue(type, label, accessor(value));
}
return (value) => {
if (value === null || typeof value !== "object") {
return undefined;
}
const fieldValue = value[accessor];
return this.#normalizeIndexValue(type, `${label} field "${accessor}"`, fieldValue);
};
}
#normalizeIndexValue(type, label, value) {
if (value === undefined || value === null) {
return undefined;
}
switch (typeof value) {
case "string":
return value;
case "number":
case "boolean":
case "bigint":
return String(value);
default:
throw new Error(`CultCache ${label} accessor for type "${type}" produced a non-scalar value.`);
}
}
#requireDefinition(definition) {
const registered = this.#definitions.get(definition.type);
if (!registered || registered.definition !== definition) {
throw new Error(`CultCache document type "${definition.type}" is not registered on this cache instance.`);
}
return registered;
}
#resolveDefinitionForEnvelope(entry) {
if (entry.schemaId) {
return this.#schemaIdDefinitions.get(entry.schemaId) ?? this.#definitions.get(entry.type);
}
return this.#definitions.get(entry.type);
}
#resolveRoute(type) {
const typeSpecific = this.#stores.filter((registration) => registration.types.includes(type));
if (typeSpecific.length > 0) {
return {
primary: typeSpecific[0]?.store,
mirrors: typeSpecific.slice(1).map((registration) => registration.store),
};
}
const generic = this.#stores.filter((registration) => registration.types.length === 0);
return {
primary: generic[0]?.store,
mirrors: generic.slice(1).map((registration) => registration.store),
};
}
#applyHydratedEntry(registered, entry, value, source) {
const entryId = this.#entryId(entry.type, entry.key);
const existing = this.#entries.get(entryId);
if (existing) {
this.#removeHydratedEntry(registered, existing);
}
if (registered.global) {
const currentGlobalKey = this.#globalKeys.get(entry.type);
if (currentGlobalKey && currentGlobalKey !== entry.key) {
throw new Error(source === "pull"
? `CultCache global document type "${entry.type}" has multiple persisted entries.`
: `CultCache global document type "${entry.type}" already has a different key "${currentGlobalKey}".`);
}
}
const hydrated = {
...entry,
payload: this.#cloneBytes(entry.payload),
schemaId: entry.schemaId ?? registered.catalogEntry.schemaId,
catalogEntry: entry.catalogEntry ?? registered.catalogEntry,
value,
};
this.#entries.set(entryId, hydrated);
this.#getOrCreateTypeEntrySet(entry.type).add(entryId);
if (registered.global) {
this.#globalKeys.set(entry.type, entry.key);
}
if (registered.nameAccessor) {
const name = registered.nameAccessor(value);
if (name !== undefined) {
this.#getOrCreateNameMap(entry.type).set(name, entry.key);
}
}
for (const [indexName, accessor] of registered.indexAccessors) {
const indexValue = accessor(value);
if (indexValue !== undefined) {
this.#getOrCreateIndexMap(entry.type, indexName).set(indexValue, entry.key);
}
}
}
#removeHydratedEntry(registered, entry) {
const entryId = this.#entryId(entry.type, entry.key);
this.#entries.delete(entryId);
const typeEntryIds = this.#typeEntryIds.get(entry.type);
if (typeEntryIds) {
typeEntryIds.delete(entryId);
if (typeEntryIds.size === 0) {
this.#typeEntryIds.delete(entry.type);
}
}
if (registered.global && this.#globalKeys.get(entry.type) === entry.key) {
this.#globalKeys.delete(entry.type);
}
if (registered.nameAccessor) {
const name = registered.nameAccessor(entry.value);
if (name !== undefined) {
this.#nameToKeyMaps.get(entry.type)?.delete(name);
}
}
for (const [indexName, accessor] of registered.indexAccessors) {
const value = accessor(entry.value);
if (value !== undefined) {
this.#indexToKeyMaps.get(this.#indexId(entry.type, indexName))?.delete(value);
}
}
}
#rebuildDefinitionLookups(registered) {
this.#rebuildNameLookup(registered);
for (const indexName of registered.indexAccessors.keys()) {
this.#rebuildIndexLookup(registered, indexName);
}
}
#rebuildNameLookup(registered) {
this.#nameToKeyMaps.delete(registered.definition.type);
if (!registered.nameAccessor) {
return;
}
const map = this.#getOrCreateNameMap(registered.definition.type);
for (const entry of this.#entriesForType(registered.definition.type)) {
const name = registered.nameAccessor(entry.value);
if (name !== undefined) {
map.set(name, entry.key);
}
}
}
#rebuildIndexLookup(registered, indexName) {
const lookupId = this.#indexId(registered.definition.type, indexName);
this.#indexToKeyMaps.delete(lookupId);
const accessor = registered.indexAccessors.get(indexName);
if (!accessor) {
return;
}
const map = this.#getOrCreateIndexMap(registered.definition.type, indexName);
for (const entry of this.#entriesForType(registered.definition.type)) {
const value = accessor(entry.value);
if (value !== undefined) {
map.set(value, entry.key);
}
}
}
#entriesForType(type) {
const entryIds = this.#typeEntryIds.get(type);
if (!entryIds) {
return [];
}
const entries = [];
for (const entryId of entryIds) {
const entry = this.#entries.get(entryId);
if (entry) {
entries.push(entry);
}
}
return entries;
}
#getOrCreateTypeEntrySet(type) {
let set = this.#typeEntryIds.get(type);
if (!set) {
set = new Set();
this.#typeEntryIds.set(type, set);
}
return set;
}
#getOrCreateNameMap(type) {
let map = this.#nameToKeyMaps.get(type);
if (!map) {
map = new Map();
this.#nameToKeyMaps.set(type, map);
}
return map;
}
#getOrCreateIndexMap(type, indexName) {
const lookupId = this.#indexId(type, indexName);
let map = this.#indexToKeyMaps.get(lookupId);
if (!map) {
map = new Map();
this.#indexToKeyMaps.set(lookupId, map);
}
return map;
}
#toEnvelope(entry) {
return {
key: entry.key,
type: entry.type,
payload: this.#cloneBytes(entry.payload),
storedAt: entry.storedAt,
schemaId: entry.schemaId,
catalogEntry: entry.catalogEntry,
};
}
#resetHydratedState() {
this.#entries.clear();
this.#typeEntryIds.clear();
this.#nameToKeyMaps.clear();
this.#indexToKeyMaps.clear();
this.#globalKeys.clear();
}
#entryId(type, key) {
return `${type}::${key}`;
}
#indexId(type, indexName) {
return `${type}::${indexName}`;
}
#cloneBytes(payload) {
return new Uint8Array(payload);
}
}
exports.CultCache = CultCache;

4
dist/document.d.ts vendored
View file

@ -1,4 +0,0 @@
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentDefinition, CultCacheDocumentRegistry, CultCacheSchema } from "./types";
export declare function defineDocumentType<TSchema extends CultCacheSchema>(definition: CultCacheDocumentDefinition<TSchema>): CultCacheDocumentDefinition<TSchema>;
export declare function defineDocumentRegistry<TDefinitions extends readonly AnyCultCacheDocumentDefinition[]>(...definitions: TDefinitions): CultCacheDocumentRegistry<TDefinitions>;
//# sourceMappingURL=document.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"document.d.ts","sourceRoot":"","sources":["../src/document.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,8BAA8B,EAC9B,2BAA2B,EAC3B,yBAAyB,EACzB,eAAe,EAChB,MAAM,SAAS,CAAC;AAEjB,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,eAAe,EAChE,UAAU,EAAE,2BAA2B,CAAC,OAAO,CAAC,GAC/C,2BAA2B,CAAC,OAAO,CAAC,CA4DtC;AAED,wBAAgB,sBAAsB,CACpC,YAAY,SAAS,SAAS,8BAA8B,EAAE,EAC9D,GAAG,WAAW,EAAE,YAAY,GAAG,yBAAyB,CAAC,YAAY,CAAC,CAIvE"}

66
dist/document.js vendored
View file

@ -1,66 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.defineDocumentType = defineDocumentType;
exports.defineDocumentRegistry = defineDocumentRegistry;
function defineDocumentType(definition) {
if (!definition.type || definition.type.trim().length === 0) {
throw new Error("CultCache document types must declare a non-empty type.");
}
for (const [fieldName, fieldValue] of [
["schemaId", definition.schemaId],
["schemaName", definition.schemaName],
["schemaVersion", definition.schemaVersion],
["contentHash", definition.contentHash],
["canonicalSchemaJson", definition.canonicalSchemaJson],
]) {
if (fieldValue !== undefined && fieldValue.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an empty ${fieldName}.`);
}
}
if (definition.name !== undefined && typeof definition.name !== "string" && typeof definition.name !== "function") {
throw new Error(`CultCache document type "${definition.type}" declares an invalid name accessor.`);
}
if (Array.isArray(definition.indexes)) {
const seen = new Set();
for (const index of definition.indexes) {
if (!index.name || index.name.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
}
if (seen.has(index.name)) {
throw new Error(`CultCache document type "${definition.type}" declares duplicate index "${index.name}".`);
}
seen.add(index.name);
}
}
else if (definition.indexes) {
for (const name of Object.keys(definition.indexes)) {
if (!name || name.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
}
}
}
if (definition.members) {
const seenSlots = new Set();
for (const member of definition.members) {
if (!Number.isInteger(member.slot) || member.slot < 0) {
throw new Error(`CultCache document type "${definition.type}" declares an invalid schema member slot.`);
}
if (seenSlots.has(member.slot)) {
throw new Error(`CultCache document type "${definition.type}" declares duplicate schema member slot ${member.slot}.`);
}
seenSlots.add(member.slot);
if (!member.memberName || member.memberName.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares a schema member with an empty name.`);
}
if (!member.typeName || member.typeName.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares schema member "${member.memberName}" with an empty type.`);
}
}
}
return Object.freeze({ ...definition });
}
function defineDocumentRegistry(...definitions) {
return Object.freeze({
definitions: Object.freeze([...definitions]),
});
}

3
dist/huginn-eve-dsl.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
import type { CultCacheInspection } from "cultcache-ts/inspection";
export declare function buildHuginnEveDsl(inspection: CultCacheInspection): string;
//# sourceMappingURL=huginn-eve-dsl.d.ts.map

1
dist/huginn-eve-dsl.d.ts.map vendored Normal file
View file

@ -0,0 +1 @@
{"version":3,"file":"huginn-eve-dsl.d.ts","sourceRoot":"","sources":["../src/huginn-eve-dsl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAA0C,MAAM,yBAAyB,CAAC;AAE3G,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,mBAAmB,GAAG,MAAM,CA8BzE"}

55
dist/huginn-eve-dsl.js vendored Normal file
View file

@ -0,0 +1,55 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildHuginnEveDsl = buildHuginnEveDsl;
function buildHuginnEveDsl(inspection) {
return [
"# Huginn emits Eve DSL. Renderers compile and lower it.",
`surface cultcache.huginn.inspector ${quoteDsl("Huginn .cc Inspector")}`,
"version 1",
"",
"collection huginn.catalog.entries",
...inspection.catalog.map((entry) => `item huginn.catalog.entries ${quoteDsl(renderCatalogEntry(entry))}`),
"",
"collection huginn.records.entries",
...inspection.records.map((record) => `item huginn.records.entries ${quoteDsl(renderRecord(record))}`),
"",
"card source \"Source Witness\"",
`text ${quoteDsl(`file: ${inspection.filePath}`)}`,
`text ${quoteDsl(`format: ${inspection.format}`)}`,
`text ${quoteDsl(`bytes: ${inspection.fileSizeBytes}`)}`,
"text \".cc bytes are authority; Huginn only emits Eve DSL.\"",
"end",
"",
"card catalog \"Schema Catalog\"",
`text ${quoteDsl(`schemas: ${inspection.catalog.length}`)}`,
"list \"Catalog Entries\" bind huginn.catalog.entries",
"end",
"",
"card records \"Persisted Records\"",
`text ${quoteDsl(`records: ${inspection.records.length}`)}`,
"list \"Record Entries\" bind huginn.records.entries",
"end",
"",
].join("\n");
}
function renderCatalogEntry(entry) {
return [
entry.schemaName,
entry.schemaVersion,
entry.schemaId,
`${entry.members.length} members`,
].join(" | ");
}
function renderRecord(record) {
const status = record.payloadDecodeError ? `decode error: ${record.payloadDecodeError}` : "decoded";
return [
record.key,
record.schemaName,
record.storedAt,
`${record.payloadBytes} bytes`,
status,
].join(" | ");
}
function quoteDsl(value) {
return JSON.stringify(value);
}

9
dist/index.d.ts vendored
View file

@ -1,7 +1,4 @@
export { CultCache, CultCacheBuilder } from "./cult-cache";
export { inspectCultCacheBytes } from "./cult-cache-inspector";
export { defineDocumentRegistry, defineDocumentType } from "./document";
export { SingleFileMessagePackBackingStore } from "./single-file-messagepack-backing-store";
export type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheDocumentAccessor, CultCacheDocumentDefinition, CultCacheDocumentFieldName, CultCacheDocumentFormatter, CultCacheDocumentIndexDefinition, CultCacheDocumentRegistry, CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember, CultCacheSchema, CultCacheDocumentValue, CultCacheEnvelope, CultCacheIndexScalar, InferCultCacheSchemaValue, CultCacheStoreRegistration, PushAllOptions, } from "./types";
export type { CultCacheInspection, InspectedCatalogEntry, InspectedCatalogMember, InspectedRecord, } from "./cult-cache-inspector";
export { buildHuginnEveDsl } from "./huginn-eve-dsl";
export { inspectCultCacheBytes } from "cultcache-ts/inspection";
export type { CultCacheInspection, InspectedCatalogEntry, InspectedCatalogMember, InspectedRecord, } from "cultcache-ts/inspection";
//# sourceMappingURL=index.d.ts.map

2
dist/index.d.ts.map vendored
View file

@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EAAE,iCAAiC,EAAE,MAAM,yCAAyC,CAAC;AAC5F,YAAY,EACV,8BAA8B,EAC9B,iBAAiB,EACjB,yBAAyB,EACzB,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,EAC1B,gCAAgC,EAChC,yBAAyB,EACzB,2BAA2B,EAC3B,4BAA4B,EAC5B,eAAe,EACf,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,yBAAyB,EACzB,0BAA0B,EAC1B,cAAc,GACf,MAAM,SAAS,CAAC;AACjB,YAAY,EACV,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,eAAe,GAChB,MAAM,wBAAwB,CAAC"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAChE,YAAY,EACV,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,eAAe,GAChB,MAAM,yBAAyB,CAAC"}

16
dist/index.js vendored
View file

@ -1,13 +1,7 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SingleFileMessagePackBackingStore = exports.defineDocumentType = exports.defineDocumentRegistry = exports.inspectCultCacheBytes = exports.CultCacheBuilder = exports.CultCache = void 0;
var cult_cache_1 = require("./cult-cache");
Object.defineProperty(exports, "CultCache", { enumerable: true, get: function () { return cult_cache_1.CultCache; } });
Object.defineProperty(exports, "CultCacheBuilder", { enumerable: true, get: function () { return cult_cache_1.CultCacheBuilder; } });
var cult_cache_inspector_1 = require("./cult-cache-inspector");
Object.defineProperty(exports, "inspectCultCacheBytes", { enumerable: true, get: function () { return cult_cache_inspector_1.inspectCultCacheBytes; } });
var document_1 = require("./document");
Object.defineProperty(exports, "defineDocumentRegistry", { enumerable: true, get: function () { return document_1.defineDocumentRegistry; } });
Object.defineProperty(exports, "defineDocumentType", { enumerable: true, get: function () { return document_1.defineDocumentType; } });
var single_file_messagepack_backing_store_1 = require("./single-file-messagepack-backing-store");
Object.defineProperty(exports, "SingleFileMessagePackBackingStore", { enumerable: true, get: function () { return single_file_messagepack_backing_store_1.SingleFileMessagePackBackingStore; } });
exports.inspectCultCacheBytes = exports.buildHuginnEveDsl = void 0;
var huginn_eve_dsl_1 = require("./huginn-eve-dsl");
Object.defineProperty(exports, "buildHuginnEveDsl", { enumerable: true, get: function () { return huginn_eve_dsl_1.buildHuginnEveDsl; } });
var inspection_1 = require("cultcache-ts/inspection");
Object.defineProperty(exports, "inspectCultCacheBytes", { enumerable: true, get: function () { return inspection_1.inspectCultCacheBytes; } });

View file

@ -1,11 +0,0 @@
import type { CacheBackingStore, CultCacheEnvelope, PushAllOptions } from "./types";
export declare class SingleFileMessagePackBackingStore implements CacheBackingStore {
#private;
readonly filePath: string;
constructor(filePath: string);
pullAll(): Promise<CultCacheEnvelope[]>;
push(entry: CultCacheEnvelope): Promise<void>;
delete(entry: CultCacheEnvelope): Promise<void>;
pushAll(entries: CultCacheEnvelope[], options?: PushAllOptions): Promise<void>;
}
//# sourceMappingURL=single-file-messagepack-backing-store.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"single-file-messagepack-backing-store.d.ts","sourceRoot":"","sources":["../src/single-file-messagepack-backing-store.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,iBAAiB,EACjB,iBAAiB,EAGjB,cAAc,EACf,MAAM,SAAS,CAAC;AAsBjB,qBAAa,iCAAkC,YAAW,iBAAiB;;IACzE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAId,QAAQ,EAAE,MAAM;IAItB,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;IA6DvC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,OAAO,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAiDzF"}

View file

@ -1,311 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SingleFileMessagePackBackingStore = void 0;
const promises_1 = require("node:fs/promises");
const node_path_1 = require("node:path");
const msgpack_1 = require("@msgpack/msgpack");
const zod_1 = require("zod");
const STORE_FORMAT_VERSION = "cultcache.store.v1";
const envelopeSchema = zod_1.z.object({
key: zod_1.z.string().min(1),
type: zod_1.z.string().min(1),
payload: zod_1.z.instanceof(Uint8Array),
storedAt: zod_1.z.string().min(1),
schemaId: zod_1.z.string().min(1).optional(),
});
const envelopeArraySchema = zod_1.z.array(envelopeSchema);
const legacyEnvelopeArraySchema = zod_1.z.array(zod_1.z.object({
key: zod_1.z.string().min(1),
type: zod_1.z.string().min(1),
payload: zod_1.z.unknown(),
storedAt: zod_1.z.string().min(1),
}));
class SingleFileMessagePackBackingStore {
filePath;
#writeQueue = Promise.resolve();
constructor(filePath) {
this.filePath = (0, node_path_1.resolve)(filePath);
}
async pullAll() {
try {
const data = await (0, promises_1.readFile)(this.filePath);
if (data.length === 0) {
return [];
}
const decoded = (0, msgpack_1.decode)(data);
const snapshot = decodeSnapshot(decoded);
if (snapshot) {
return snapshot.records.map((record) => {
const catalogEntry = snapshot.catalogBySchemaId.get(record.schemaId);
if (!catalogEntry) {
throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`);
}
return {
key: record.key,
type: catalogEntry.schemaName,
schemaId: record.schemaId,
catalogEntry,
payload: record.payload,
storedAt: record.storedAt,
};
});
}
const legacy = decodeLegacyEnvelopeArray(decoded);
if (legacy) {
let repairedLegacyPayload = false;
const normalized = legacy.map((entry) => {
const payload = normalizePayload(entry.payload);
if (payload !== entry.payload) {
repairedLegacyPayload = true;
}
return {
...entry,
payload,
};
});
const parsed = envelopeArraySchema.parse(normalized);
if (repairedLegacyPayload) {
await this.#writeAll(parsed);
}
return parsed;
}
throw new Error(`CultCache file ${this.filePath} is not a recognized CultCache MessagePack store.`);
}
catch (error) {
const code = error.code;
if (code === "ENOENT") {
return [];
}
throw error;
}
}
async push(entry) {
await this.#enqueue(async () => {
const existing = await this.pullAll();
const filtered = existing.filter((candidate) => !(candidate.type === entry.type && candidate.key === entry.key));
filtered.push(entry);
await this.#writeAll(filtered);
});
}
async delete(entry) {
await this.#enqueue(async () => {
const existing = await this.pullAll();
const filtered = existing.filter((candidate) => !(candidate.type === entry.type && candidate.key === entry.key));
await this.#writeAll(filtered);
});
}
async pushAll(entries, options = {}) {
await this.#enqueue(async () => {
if (options.soft) {
try {
await (0, promises_1.readFile)(this.filePath);
return;
}
catch (error) {
const code = error.code;
if (code !== "ENOENT") {
throw error;
}
}
}
await this.#writeAll(entries);
});
}
async #enqueue(operation) {
let result;
const next = this.#writeQueue.then(async () => {
result = await operation();
});
this.#writeQueue = next.then(() => undefined, () => undefined);
await next;
return result;
}
async #writeAll(entries) {
await (0, promises_1.mkdir)((0, node_path_1.dirname)(this.filePath), { recursive: true });
const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}`;
try {
await (0, promises_1.writeFile)(tempPath, (0, msgpack_1.encode)(encodeSnapshot(entries)));
await (0, promises_1.rename)(tempPath, this.filePath);
}
catch (error) {
await (0, promises_1.rm)(tempPath, { force: true }).catch(() => undefined);
throw error;
}
}
}
exports.SingleFileMessagePackBackingStore = SingleFileMessagePackBackingStore;
function encodeSnapshot(entries) {
const catalog = [...catalogEntriesFor(entries).values()]
.sort((left, right) => compareOrdinal(left.schemaName, right.schemaName));
const records = [...entries]
.sort((left, right) => compareOrdinal(left.key, right.key))
.map((entry) => [
entry.key,
schemaIdForEnvelope(entry),
entry.storedAt,
entry.payload,
]);
return [
STORE_FORMAT_VERSION,
catalog.map(encodeCatalogEntry),
records,
];
}
function catalogEntriesFor(entries) {
const catalog = new Map();
for (const entry of entries) {
const schemaId = schemaIdForEnvelope(entry);
if (!catalog.has(schemaId)) {
catalog.set(schemaId, entry.catalogEntry ?? {
schemaId,
schemaName: entry.type,
schemaVersion: `${entry.type}.v1`,
contentHash: schemaId,
canonicalSchemaJson: JSON.stringify({
schemaName: entry.type,
schemaVersion: `${entry.type}.v1`,
members: [],
}),
compatibleSchemaIds: [schemaId],
members: [],
});
}
}
return catalog;
}
function schemaIdForEnvelope(entry) {
return entry.schemaId ?? entry.type;
}
function encodeCatalogEntry(entry) {
return [
entry.schemaId,
entry.schemaName,
entry.schemaVersion,
entry.contentHash,
entry.canonicalSchemaJson,
[...(entry.compatibleSchemaIds ?? [entry.schemaId])],
[...(entry.members ?? [])].map(encodeCatalogMember),
];
}
function encodeCatalogMember(member) {
return [
member.slot,
member.memberName,
member.typeName,
member.isReference === true,
member.isMany === true,
member.targetSchemaName ?? null,
member.isName === true,
member.indexAlias ?? null,
];
}
function decodeSnapshot(decoded) {
if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) {
return undefined;
}
const catalogRaw = decoded[1];
const recordsRaw = decoded[2];
if (!Array.isArray(catalogRaw) || !Array.isArray(recordsRaw)) {
throw new Error("CultCache v1 snapshot must contain a schema catalog and record array.");
}
const catalogBySchemaId = new Map();
for (const entry of catalogRaw) {
const catalogEntry = decodeCatalogEntry(entry);
catalogBySchemaId.set(catalogEntry.schemaId, catalogEntry);
for (const compatibleSchemaId of catalogEntry.compatibleSchemaIds ?? []) {
catalogBySchemaId.set(compatibleSchemaId, catalogEntry);
}
}
const records = recordsRaw.map(decodeRecord);
return { catalogBySchemaId, records };
}
function decodeCatalogEntry(value) {
if (!Array.isArray(value)) {
throw new Error("CultCache schema catalog entries must be MessagePack arrays.");
}
const [schemaId = "", schemaName = "", schemaVersion = "", contentHash = "", canonicalSchemaJson = "", compatibleSchemaIds = [], members = [],] = value;
if (!isNonEmptyString(schemaId) || !isNonEmptyString(schemaName)) {
throw new Error("CultCache schema catalog entries must declare schemaId and schemaName.");
}
if (!Array.isArray(compatibleSchemaIds) || !compatibleSchemaIds.every(isNonEmptyString)) {
throw new Error(`CultCache schema catalog entry "${schemaId}" has invalid compatible schema ids.`);
}
if (!Array.isArray(members)) {
throw new Error(`CultCache schema catalog entry "${schemaId}" has invalid members.`);
}
return {
schemaId,
schemaName,
schemaVersion: isNonEmptyString(schemaVersion) ? schemaVersion : `${schemaName}.v1`,
contentHash: isNonEmptyString(contentHash) ? contentHash : schemaId,
canonicalSchemaJson: typeof canonicalSchemaJson === "string" ? canonicalSchemaJson : "",
compatibleSchemaIds,
members: members.map(decodeCatalogMember),
};
}
function decodeCatalogMember(value) {
if (!Array.isArray(value)) {
throw new Error("CultCache schema catalog members must be MessagePack arrays.");
}
const [slot = -1, memberName = "", typeName = "", isReference = false, isMany = false, targetSchemaName = null, isName = false, indexAlias = null,] = value;
if (!Number.isInteger(slot) || slot < 0 || !isNonEmptyString(memberName) || !isNonEmptyString(typeName)) {
throw new Error("CultCache schema catalog member has invalid slot, name, or type.");
}
return {
slot,
memberName,
typeName,
isReference: isReference === true,
isMany: isMany === true,
targetSchemaName: typeof targetSchemaName === "string" ? targetSchemaName : null,
isName: isName === true,
indexAlias: typeof indexAlias === "string" ? indexAlias : null,
};
}
function decodeRecord(value) {
if (!Array.isArray(value)) {
throw new Error("CultCache persisted records must be MessagePack arrays.");
}
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
}
return {
key,
schemaId,
storedAt,
payload: normalizePayload(payload),
};
}
function decodeLegacyEnvelopeArray(decoded) {
if (!Array.isArray(decoded)) {
return undefined;
}
return legacyEnvelopeArraySchema.parse(decoded);
}
function normalizePayload(payload) {
if (payload instanceof Uint8Array) {
return payload;
}
if (isObject(payload) &&
payload.type === "Buffer" &&
Array.isArray(payload.data) &&
payload.data.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) {
return Uint8Array.from(payload.data);
}
if (Array.isArray(payload) && payload.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) {
return Uint8Array.from(payload);
}
return (0, msgpack_1.encode)(payload);
}
function isObject(value) {
return typeof value === "object" && value !== null;
}
function isNonEmptyString(value) {
return typeof value === "string" && value.length > 0;
}
function compareOrdinal(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}

76
dist/types.d.ts vendored
View file

@ -1,76 +0,0 @@
export type CultCacheIndexScalar = string | number | boolean | bigint;
export interface CultCacheSchema<TValue = unknown> {
parse(input: unknown): TValue;
}
export type InferCultCacheSchemaValue<TSchema extends CultCacheSchema> = TSchema extends CultCacheSchema<infer TValue> ? TValue : never;
export type CultCacheDocumentFieldName<TValue> = TValue extends object ? Extract<keyof TValue, string> : never;
export type CultCacheDocumentAccessor<TValue> = CultCacheDocumentFieldName<TValue> | ((value: TValue) => CultCacheIndexScalar | null | undefined);
export interface CultCacheDocumentFormatter<TValue> {
encode(value: TValue): Uint8Array;
decode(payload: Uint8Array): TValue;
}
export interface CultCacheSchemaCatalogMember {
slot: number;
memberName: string;
typeName: string;
isReference?: boolean;
isMany?: boolean;
targetSchemaName?: string | null;
isName?: boolean;
indexAlias?: string | null;
}
export interface CultCacheSchemaCatalogEntry {
schemaId: string;
schemaName: string;
schemaVersion: string;
contentHash: string;
canonicalSchemaJson: string;
compatibleSchemaIds?: readonly string[];
members?: readonly CultCacheSchemaCatalogMember[];
}
export interface CultCacheDocumentIndexDefinition<TValue> {
name: string;
accessor: CultCacheDocumentAccessor<TValue>;
}
export interface CultCacheDocumentDefinition<TSchema extends CultCacheSchema = CultCacheSchema> {
type: string;
schema: TSchema;
schemaId?: string;
schemaName?: string;
schemaVersion?: string;
contentHash?: string;
canonicalSchemaJson?: string;
compatibleSchemaIds?: readonly string[];
members?: readonly CultCacheSchemaCatalogMember[];
global?: boolean;
name?: CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>;
indexes?: Readonly<Record<string, CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>>> | readonly CultCacheDocumentIndexDefinition<InferCultCacheSchemaValue<TSchema>>[];
formatter?: CultCacheDocumentFormatter<InferCultCacheSchemaValue<TSchema>>;
}
export type CultCacheDocumentValue<TDefinition extends CultCacheDocumentDefinition> = InferCultCacheSchemaValue<TDefinition["schema"]>;
export type AnyCultCacheDocumentDefinition = CultCacheDocumentDefinition<any>;
export interface CultCacheDocumentRegistry<TDefinitions extends readonly AnyCultCacheDocumentDefinition[] = readonly AnyCultCacheDocumentDefinition[]> {
readonly definitions: TDefinitions;
}
export interface CultCacheEnvelope {
key: string;
type: string;
payload: Uint8Array;
storedAt: string;
schemaId?: string;
catalogEntry?: CultCacheSchemaCatalogEntry;
}
export interface PushAllOptions {
soft?: boolean;
}
export interface CacheBackingStore {
pullAll(): Promise<CultCacheEnvelope[]>;
push(entry: CultCacheEnvelope): Promise<void>;
delete(entry: CultCacheEnvelope): Promise<void>;
pushAll?(entries: CultCacheEnvelope[], options?: PushAllOptions): Promise<void>;
}
export interface CultCacheStoreRegistration {
store: CacheBackingStore;
types: string[];
}
//# sourceMappingURL=types.d.ts.map

1
dist/types.d.ts.map vendored
View file

@ -1 +0,0 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,oBAAoB,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAEtE,MAAM,WAAW,eAAe,CAAC,MAAM,GAAG,OAAO;IAC/C,KAAK,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC;CAC/B;AAED,MAAM,MAAM,yBAAyB,CAAC,OAAO,SAAS,eAAe,IACnE,OAAO,SAAS,eAAe,CAAC,MAAM,MAAM,CAAC,GAAG,MAAM,GAAG,KAAK,CAAC;AAEjE,MAAM,MAAM,0BAA0B,CAAC,MAAM,IAAI,MAAM,SAAS,MAAM,GAAG,OAAO,CAAC,MAAM,MAAM,EAAE,MAAM,CAAC,GAAG,KAAK,CAAC;AAE/G,MAAM,MAAM,yBAAyB,CAAC,MAAM,IACxC,0BAA0B,CAAC,MAAM,CAAC,GAClC,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,oBAAoB,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;AAEjE,MAAM,WAAW,0BAA0B,CAAC,MAAM;IAChD,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAAC;IAClC,MAAM,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,CAAC;CACrC;AAED,MAAM,WAAW,4BAA4B;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,OAAO,CAAC,EAAE,SAAS,4BAA4B,EAAE,CAAC;CACnD;AAED,MAAM,WAAW,gCAAgC,CAAC,MAAM;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,yBAAyB,CAAC,MAAM,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,2BAA2B,CAAC,OAAO,SAAS,eAAe,GAAG,eAAe;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,OAAO,CAAC,EAAE,SAAS,4BAA4B,EAAE,CAAC;IAClD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,yBAAyB,CAAC,yBAAyB,CAAC,OAAO,CAAC,CAAC,CAAC;IACrE,OAAO,CAAC,EACJ,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,yBAAyB,CAAC,yBAAyB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,GACvF,SAAS,gCAAgC,CAAC,yBAAyB,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;IACpF,SAAS,CAAC,EAAE,0BAA0B,CAAC,yBAAyB,CAAC,OAAO,CAAC,CAAC,CAAC;CAC5E;AAED,MAAM,MAAM,sBAAsB,CAAC,WAAW,SAAS,2BAA2B,IAAI,yBAAyB,CAC7G,WAAW,CAAC,QAAQ,CAAC,CACtB,CAAC;AAEF,MAAM,MAAM,8BAA8B,GAAG,2BAA2B,CAAC,GAAG,CAAC,CAAC;AAE9E,MAAM,WAAW,yBAAyB,CACxC,YAAY,SAAS,SAAS,8BAA8B,EAAE,GAAG,SAAS,8BAA8B,EAAE;IAE1G,QAAQ,CAAC,WAAW,EAAE,YAAY,CAAC;CACpC;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,UAAU,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,2BAA2B,CAAC;CAC5C;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACxC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,OAAO,CAAC,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjF;AAED,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,iBAAiB,CAAC;IACzB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB"}

2
dist/types.js vendored
View file

@ -1,2 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View file

@ -1,28 +0,0 @@
{
"appId": "com.gamecult.cultcache.inspector",
"productName": "Huginn",
"executableName": "Huginn",
"asar": false,
"icon": "inspector/public/hugin.ico",
"extraMetadata": {
"main": "electron/main.cjs"
},
"files": [
"electron/main.cjs",
"dist-inspector/**/*"
],
"directories": {
"output": "release-inspector"
},
"win": {
"signAndEditExecutable": false,
"target": ["portable"],
"artifactName": "Huginn-${version}.${ext}"
},
"mac": {
"target": ["dir"]
},
"linux": {
"target": ["dir"]
}
}

View file

@ -1,72 +0,0 @@
const { app, BrowserWindow } = require("electron");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const devServer = process.env.CULTCACHE_INSPECTOR_DEV_SERVER;
app.setName("Huginn");
const logPath = path.join(app.getPath("userData"), "huginn-renderer.log");
function writeLog(message) {
const line = `[${new Date().toISOString()}] ${message}${os.EOL}`;
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.appendFileSync(logPath, line);
} catch {
// Logging must never be the reason the inspector refuses to open.
}
console.log(line.trimEnd());
}
function createWindow() {
const window = new BrowserWindow({
width: 1180,
height: 780,
minWidth: 820,
minHeight: 560,
title: "Huginn",
icon: path.join(__dirname, "..", "dist-inspector", "hugin-64.png"),
backgroundColor: "#05090d",
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
window.webContents.on("console-message", (_event, level, message, line, sourceId) => {
writeLog(`renderer console[${level}] ${sourceId}:${line} ${message}`);
});
window.webContents.on("did-fail-load", (_event, code, description, validatedUrl) => {
writeLog(`renderer failed load ${code} ${description} ${validatedUrl}`);
});
window.webContents.on("render-process-gone", (_event, details) => {
writeLog(`renderer process gone ${details.reason} exitCode=${details.exitCode}`);
});
window.webContents.on("did-finish-load", () => {
writeLog(`renderer loaded ${window.webContents.getURL()}`);
});
if (devServer) {
void window.loadURL(devServer);
} else {
void window.loadFile(path.join(__dirname, "..", "dist-inspector", "index.html"));
}
}
app.whenReady().then(() => {
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});

View file

@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Huginn</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load diff

View file

@ -1,856 +0,0 @@
import { EpiphanyGraphViewer, type EpiphanyGraphEdge, type EpiphanyGraphNode, type EpiphanyGraphsState, type ViewerSelection } from "@epiphanygraph/epiphany-graph-viewer";
import { createRoot } from "react-dom/client";
import { Component, useMemo, useState, type DragEvent, type ReactNode } from "react";
import { inspectCultCacheBytes, type CultCacheInspection, type InspectedCatalogEntry, type InspectedRecord } from "../../src/cult-cache-inspector";
import { HuginnFieldCanvas } from "./HuginnFieldCanvas";
import "./styles.css";
type Selection = {
record: number;
};
type NodeArticle = NonNullable<Parameters<typeof EpiphanyGraphViewer>[0]["nodeArticle"]>;
type RawSelection =
| { kind: "record"; index: number }
| { kind: "value"; recordIndex: number; path: string; value: unknown };
type GraphProjection = {
state: EpiphanyGraphsState;
recordNodeIds: string[];
nodeSelections: Map<string, RawSelection>;
edgeSelections: Map<string, RawSelection>;
collapsedPayloadValues: number;
};
const MAX_PAYLOAD_REGION_NODES = 160;
const MAX_RECORD_REGION_NODES = 32;
const MAX_PAYLOAD_REGION_DEPTH = 3;
const MAX_PAYLOAD_REGION_CHILDREN = 10;
const MAX_NESTED_PAYLOAD_REGION_CHILDREN = 6;
const assetPath = (name: string) => `${import.meta.env.BASE_URL}${name}`;
const HUGINN_ART = {
surface: assetPath("huginn-surface.png"),
field: assetPath("huginn-fieldpack.png"),
};
function HuginnField() {
return (
<HuginnFieldCanvas
imageUrl={HUGINN_ART.surface}
fieldUrl={HUGINN_ART.field}
/>
);
}
const app = document.querySelector<HTMLDivElement>("#app");
if (!app) {
throw new Error("Missing inspector app root.");
}
class RenderCrashBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
state = { error: null };
static getDerivedStateFromError(error: unknown): { error: string } {
return { error: error instanceof Error ? error.message : String(error) };
}
render(): ReactNode {
if (this.state.error) {
return (
<main className="workspace">
<section className="sidebar">
<div className="brand">
<img src={assetPath("hugin-64.png")} alt="" width="64" height="64" />
<div>
<h1>Huginn</h1>
<p>CultCache state inspection for <code>.cc</code> files.</p>
</div>
</div>
</section>
<section className="content">
<div className="error fatal-error">
<strong>Renderer error</strong>
<span>{this.state.error}</span>
</div>
</section>
</main>
);
}
return this.props.children;
}
}
createRoot(app).render(
<RenderCrashBoundary>
<HuginnApp />
</RenderCrashBoundary>,
);
function HuginnApp() {
const [inspection, setInspection] = useState<CultCacheInspection | undefined>();
const [selection, setSelection] = useState<Selection>({ record: 0 });
const [graphSelection, setGraphSelection] = useState<ViewerSelection | null>(null);
const [errorMessage, setErrorMessage] = useState("");
const [dragging, setDragging] = useState(false);
const graphProjection = useMemo(
() => inspection ? buildGraphProjection(inspection) : emptyGraphProjection(),
[inspection],
);
async function inspectFile(file: File): Promise<void> {
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const nextInspection = inspectCultCacheBytes(file.name, bytes, file.size);
setInspection(nextInspection);
setSelection({ record: 0 });
setGraphSelection({
kind: "node",
graphKey: "dataflow",
nodeId: nextInspection.records[0]
? recordNodeId(nextInspection.records[0], 0)
: "store",
});
setErrorMessage("");
} catch (error) {
setInspection(undefined);
setErrorMessage(error instanceof Error ? error.message : String(error));
}
}
function handleDrop(event: DragEvent<HTMLElement>): void {
event.preventDefault();
setDragging(false);
const file = event.dataTransfer.files.item(0);
if (file) {
void inspectFile(file);
}
}
return (
<main
className={`workspace ${dragging ? "is-dragging" : ""}`}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={(event) => {
if (event.target === event.currentTarget) {
setDragging(false);
}
}}
onDrop={handleDrop}
>
{inspection
? (
<InspectionView
errorMessage={errorMessage}
inspection={inspection}
graphProjection={graphProjection}
graphSelection={graphSelection}
setGraphSelection={setGraphSelection}
selection={selection}
setSelection={setSelection}
onInspectFile={inspectFile}
/>
)
: (
<EmptyWorkspace
errorMessage={errorMessage}
onInspectFile={inspectFile}
/>
)}
</main>
);
}
function EmptyWorkspace({
errorMessage,
onInspectFile,
}: {
errorMessage: string;
onInspectFile: (file: File) => Promise<void>;
}) {
return (
<section className="empty-workspace">
<HuginnField />
<section className="floating-panel brand-panel is-primary">
<BrandBlock />
<FilePicker onInspectFile={onInspectFile} />
{errorMessage ? <div className="error">{errorMessage}</div> : null}
<EmptySummary />
</section>
<NoFile />
</section>
);
}
function BrandBlock() {
return (
<div className="brand">
<img src={assetPath("hugin-64.png")} alt="" width="64" height="64" />
<div>
<h1>Huginn</h1>
<p>CultCache state inspection for <code>.cc</code> files.</p>
</div>
</div>
);
}
function FilePicker({ onInspectFile }: { onInspectFile: (file: File) => Promise<void> }) {
return (
<label className="dropzone">
<input
type="file"
accept=".cc,.msgpack,.mpack,application/octet-stream"
onChange={(event) => {
const file = event.currentTarget.files?.item(0);
if (file) {
void onInspectFile(file);
}
}}
/>
<span className="drop-title">Drop CultCache file</span>
<span className="drop-copy">or choose one from disk</span>
</label>
);
}
function Summary({ inspection }: { inspection: CultCacheInspection }) {
return (
<div className="summary">
<Metric label="Format" value={inspection.format} />
<Metric label="Records" value={inspection.records.length.toString()} />
<Metric label="Schemas" value={inspection.catalog.length.toString()} />
<Metric label="Bytes" value={inspection.fileSizeBytes.toString()} />
</div>
);
}
function EmptySummary() {
return (
<div className="summary">
<Metric label="Format" value=".cc" />
<Metric label="Mode" value="Read-only" />
<Metric label="Payload" value="MessagePack" />
<Metric label="Runtime" value="Vite" />
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function NoFile() {
return (
<div className="hero">
<img src={HUGINN_ART.surface} alt="" />
<h2>No state loaded</h2>
<p>Drag a CultCache <code>.cc</code> store into the window to inspect the snapshot header, schema catalog, records, and decoded MessagePack payloads.</p>
</div>
);
}
function InspectionView({
errorMessage,
inspection,
graphProjection,
graphSelection,
setGraphSelection,
selection,
setSelection,
onInspectFile,
}: {
errorMessage: string;
inspection: CultCacheInspection;
graphProjection: GraphProjection;
graphSelection: ViewerSelection | null;
setGraphSelection: (selection: ViewerSelection | null) => void;
selection: Selection;
setSelection: (selection: Selection) => void;
onInspectFile: (file: File) => Promise<void>;
}) {
const selectRaw = (next: Extract<RawSelection, { kind: "record" }>) => {
setSelection({ record: next.index });
setGraphSelection({
kind: "node",
graphKey: "dataflow",
nodeId: graphProjection.recordNodeIds[next.index],
});
};
const selectGraph = (next: ViewerSelection | null) => {
setGraphSelection(next);
if (!next) {
return;
}
const rawSelection = next.kind === "node"
? graphProjection.nodeSelections.get(next.nodeId)
: graphProjection.edgeSelections.get(next.edgeId);
if (!rawSelection) {
return;
}
if (rawSelection.kind === "record") {
setSelection({ record: rawSelection.index });
return;
}
if (rawSelection.kind === "value") {
setSelection({ record: rawSelection.recordIndex });
return;
}
};
const nodeArticle: NodeArticle = {
className: "huginn-expanded-node",
ariaLabel: (node) => `${node.title} CultCache detail`,
content: (node) => {
const rawSelection = graphProjection.nodeSelections.get(node.id);
const record = rawSelection?.kind === "record"
? inspection.records[rawSelection.index]
: rawSelection?.kind === "value"
? inspection.records[rawSelection.recordIndex]
: undefined;
const expandedValue = rawSelection?.kind === "value"
? {
path: rawSelection.path,
value: rawSelection.value,
}
: undefined;
const catalogEntry = record
? inspection.catalog.find((entry) => entry.schemaId === record.schemaId)
: undefined;
return (
<ExpandedNodePanel
catalogEntry={catalogEntry}
expandedValue={expandedValue}
inspection={inspection}
record={record}
/>
);
},
};
return (
<div className="inspector-stage">
<section className="graph-panel">
<EpiphanyGraphViewer
className="huginn-graph-shell"
state={graphProjection.state}
initialGraph="dataflow"
selection={graphSelection}
onSelectionChange={selectGraph}
title="CultCache Structure"
viewportBackdrop={<HuginnField />}
viewportBackground="#03070a"
layoutMode={{ architecture: "force", dataflow: "force" }}
motion={{
strength: 1.24,
damping: 0.88,
flow: 1.2,
orbit: 0.84,
lift: 0.9,
pulse: 1,
emitNodeEnvelopes: true,
}}
overlayPanels
showSidebar={false}
nodeArticle={nodeArticle}
graphLabels={{ architecture: "File", dataflow: "Payload Cloud" }}
style={{ minHeight: "100vh" }}
/>
{graphProjection.collapsedPayloadValues > 0 ? (
<div className="graph-warning">
Payload cloud collapsed {graphProjection.collapsedPayloadValues} leaf/deep value(s) into article detail. The graph shows records and inspectable regions, not every scalar slot.
</div>
) : null}
</section>
<div className="overlay-layer">
<section className="floating-panel brand-panel">
<BrandBlock />
<FilePicker onInspectFile={onInspectFile} />
{errorMessage ? <div className="error">{errorMessage}</div> : null}
<Summary inspection={inspection} />
</section>
<section className="floating-panel data-panel records-panel">
<PanelHeader title="Records" count={inspection.records.length.toString()} />
<div className="list">
{inspection.records.length
? inspection.records.map((entry, index) => (
<RecordButton
key={`${entry.schemaId}:${entry.key}:${index}`}
record={entry}
index={index}
selected={index === selection.record}
onSelect={() => selectRaw({ kind: "record", index })}
/>
))
: <div className="empty">No records</div>}
</div>
</section>
</div>
</div>
);
}
function PanelHeader({ title, count }: { title: string; count: string }) {
return (
<header>
<h2>{title}</h2>
<span>{count}</span>
</header>
);
}
function ExpandedNodePanel({
catalogEntry,
expandedValue,
inspection,
record,
}: {
catalogEntry: InspectedCatalogEntry | undefined;
expandedValue: { path: string; value: unknown } | undefined;
inspection: CultCacheInspection;
record: InspectedRecord | undefined;
}) {
const title = expandedValue?.path ?? record?.key ?? catalogEntry?.schemaName ?? inspection.filePath;
const payloadValue = expandedValue?.value ?? record?.payloadPreview;
return (
<article className="expanded-content">
<div className="expanded-kicker">{expandedValue ? "Payload Value" : "CultCache Node"}</div>
<header className="expanded-header">
<h1>{title}</h1>
<div className="expanded-chips">
<span>{inspection.format}</span>
{record ? <span>{record.schemaName}</span> : <span>{inspection.records.length} records</span>}
{expandedValue
? <span>{valueStatus(expandedValue.value)}</span>
: record ? <span>{record.payloadBytes} bytes</span> : <span>{inspection.catalog.length} schemas</span>}
</div>
</header>
<section className="expanded-summary">
{expandedValue && record ? (
<p>{expandedValue.path} inside {record.key}.</p>
) : record ? (
<>
<p>{record.schemaName} record stored at {record.storedAt}.</p>
{record.payloadDecodeError ? <div className="error">{record.payloadDecodeError}</div> : null}
</>
) : (
<p>{inspection.filePath} contains a schema catalog and persisted MessagePack record set.</p>
)}
</section>
{payloadValue !== undefined ? (
<section className="expanded-payload">
<h2>Payload</h2>
<PayloadCode value={payloadValue} />
</section>
) : null}
<section className="expanded-article">
<div>
<h2>Record Metadata</h2>
{record ? <RecordFacts record={record} /> : <div className="empty">Select a record node to inspect payload data.</div>}
</div>
<div>
<h2>Schema Entry</h2>
{catalogEntry ? <CatalogDetail entry={catalogEntry} /> : <div className="empty">Select a schema node to inspect catalog data.</div>}
</div>
</section>
<footer className="expanded-footer">
Record {record ? inspection.records.indexOf(record) + 1 : "-"} of {inspection.records.length}
</footer>
</article>
);
}
function RecordButton({
record,
index,
selected,
onSelect,
}: {
record: InspectedRecord;
index: number;
selected: boolean;
onSelect: () => void;
}) {
return (
<button className="row" type="button" data-record={index} aria-selected={selected} onClick={onSelect}>
<strong>{record.key}</strong>
<span>{record.schemaName} - {record.payloadBytes} bytes</span>
</button>
);
}
function RecordFacts({ record }: { record: InspectedRecord }) {
return (
<Facts rows={[
["Key", record.key],
["Schema", record.schemaName],
["Schema ID", record.schemaId],
["Stored", record.storedAt],
["Payload", `${record.payloadBytes} bytes`],
]} />
);
}
function PayloadCode({ value }: { value: unknown }) {
return (
<pre className="payload-code">{JSON.stringify(value, null, 2)}</pre>
);
}
function CatalogDetail({ entry }: { entry: InspectedCatalogEntry }) {
return (
<>
<Facts rows={[
["Schema", entry.schemaName],
["Version", entry.schemaVersion],
["Schema ID", entry.schemaId],
["Hash", entry.contentHash],
["Compatible", entry.compatibleSchemaIds.join(", ")],
]} />
<pre>{JSON.stringify({
members: entry.members,
canonicalSchemaJson: parseJson(entry.canonicalSchemaJson),
}, null, 2)}</pre>
</>
);
}
function Facts({ rows }: { rows: Array<[string, string]> }) {
return (
<dl>
{rows.map(([name, value]) => (
<div className="fact-row" key={name}>
<dt>{name}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
);
}
function buildGraphProjection(inspection: CultCacheInspection): GraphProjection {
const dataflowNodes: EpiphanyGraphNode[] = [];
const dataflowEdges: EpiphanyGraphEdge[] = [];
const recordNodeIds: string[] = [];
const nodeSelections = new Map<string, RawSelection>();
const edgeSelections = new Map<string, RawSelection>();
let collapsedPayloadValues = 0;
const architectureNodes: EpiphanyGraphNode[] = [
{
id: "store",
title: ".cc Store",
purpose: `${inspection.format} file with ${inspection.records.length} record(s) and ${inspection.catalog.length} schema catalog entr${inspection.catalog.length === 1 ? "y" : "ies"}.`,
mechanism: inspection.filePath,
status: "source",
},
{
id: "catalog",
title: "Schema Catalog",
purpose: "Maps persisted schema ids to schema names, versions, compatible ids, and slot metadata.",
status: `${inspection.catalog.length} schemas`,
},
{
id: "records",
title: "Records",
purpose: "Persisted document entries carrying key, schema id, timestamp, and raw MessagePack payload bytes.",
status: `${inspection.records.length} records`,
},
];
const architectureEdges: EpiphanyGraphEdge[] = [
{
id: "store-catalog",
source_id: "store",
target_id: "catalog",
kind: "contains",
label: "schema catalog",
},
{
id: "store-records",
source_id: "store",
target_id: "records",
kind: "contains",
label: "record set",
},
];
dataflowNodes.push({
id: "store",
title: ".cc Store",
purpose: `Payload regions for ${inspection.format} snapshot.`,
mechanism: inspection.filePath,
status: `${inspection.fileSizeBytes} bytes`,
});
inspection.records.forEach((record, recordIndex) => {
const currentRecordNodeId = recordNodeId(record, recordIndex);
const recordSelection: RawSelection = { kind: "record", index: recordIndex };
const perRecordBudget = {
remainingRegionNodes: Math.min(
MAX_RECORD_REGION_NODES,
Math.floor(MAX_PAYLOAD_REGION_NODES / Math.max(1, inspection.records.length)),
),
collapsedPayloadValues: 0,
};
recordNodeIds[recordIndex] = currentRecordNodeId;
nodeSelections.set(currentRecordNodeId, recordSelection);
dataflowNodes.push({
id: currentRecordNodeId,
title: record.key,
purpose: valuePurpose(record.payloadPreview),
mechanism: record.schemaName,
status: `${record.payloadBytes} bytes`,
});
const recordEdgeId = edgeId("store", currentRecordNodeId, "contains");
edgeSelections.set(recordEdgeId, recordSelection);
dataflowEdges.push({
id: recordEdgeId,
source_id: "store",
target_id: currentRecordNodeId,
kind: "contains",
label: "record",
});
appendPayloadRegions({
nodes: dataflowNodes,
edges: dataflowEdges,
nodeSelections,
edgeSelections,
recordIndex,
parentId: currentRecordNodeId,
value: record.payloadPreview,
path: "payload",
depth: 0,
budget: perRecordBudget,
});
collapsedPayloadValues += perRecordBudget.collapsedPayloadValues;
});
return {
state: {
architecture: { nodes: architectureNodes, edges: architectureEdges },
dataflow: { nodes: dataflowNodes, edges: dataflowEdges },
links: [],
},
recordNodeIds,
nodeSelections,
edgeSelections,
collapsedPayloadValues,
};
}
function appendPayloadRegions({
nodes,
edges,
nodeSelections,
edgeSelections,
recordIndex,
parentId,
value,
path,
depth,
budget,
}: {
nodes: EpiphanyGraphNode[];
edges: EpiphanyGraphEdge[];
nodeSelections: Map<string, RawSelection>;
edgeSelections: Map<string, RawSelection>;
recordIndex: number;
parentId: string;
value: unknown;
path: string;
depth: number;
budget: { remainingRegionNodes: number; collapsedPayloadValues: number };
}): void {
if (depth >= MAX_PAYLOAD_REGION_DEPTH) {
budget.collapsedPayloadValues += countPayloadDescendants(value);
return;
}
const entries = payloadRegionEntries(value, path);
if (!entries.length) {
return;
}
let emittedChildren = 0;
const childLimit = depth === 0 ? MAX_PAYLOAD_REGION_CHILDREN : MAX_NESTED_PAYLOAD_REGION_CHILDREN;
for (const entry of entries) {
if (!isGraphworthyRegion(entry.value, depth)) {
budget.collapsedPayloadValues += 1 + countPayloadDescendants(entry.value);
continue;
}
if (budget.remainingRegionNodes <= 0 || emittedChildren >= childLimit) {
budget.collapsedPayloadValues += 1 + countPayloadDescendants(entry.value);
continue;
}
budget.remainingRegionNodes -= 1;
emittedChildren += 1;
const childId = nodeId("region", `${parentId}:${entry.path}`);
const rawSelection: RawSelection = { kind: "value", recordIndex, path: entry.path, value: entry.value };
nodeSelections.set(childId, rawSelection);
nodes.push(regionNode(childId, entry.label, entry.value, entry.path));
const edge = valueEdge(parentId, childId, entry.kind, entry.label);
edgeSelections.set(edge.id!, rawSelection);
edges.push(edge);
appendPayloadRegions({
nodes,
edges,
nodeSelections,
edgeSelections,
recordIndex,
parentId: childId,
value: entry.value,
path: entry.path,
depth: depth + 1,
budget,
});
}
}
function payloadRegionEntries(
value: unknown,
path: string,
): Array<{ label: string; value: unknown; path: string; kind: "field" | "slot" }> {
if (Array.isArray(value)) {
return value.map((item, index) => ({
label: `[${index}]`,
value: item,
path: `${path}[${index}]`,
kind: "slot",
}));
}
if (isPlainObject(value)) {
return Object.entries(value).map(([key, item]) => ({
label: key,
value: item,
path: `${path}.${key}`,
kind: "field",
}));
}
return [];
}
function isGraphworthyRegion(value: unknown, depth: number): boolean {
if (!Array.isArray(value) && !isPlainObject(value)) {
return false;
}
const directChildren = payloadRegionEntries(value, "payload").length;
if (directChildren >= (depth === 0 ? 2 : 4)) {
return true;
}
return countPayloadDescendants(value, 64) >= 10;
}
function countPayloadDescendants(value: unknown, limit = 20_000): number {
let count = 0;
const stack: unknown[] = [value];
while (stack.length && count < limit) {
const current = stack.pop();
const children = Array.isArray(current)
? current
: isPlainObject(current)
? Object.values(current)
: [];
for (const child of children) {
count += 1;
if (Array.isArray(child) || isPlainObject(child)) {
stack.push(child);
}
if (count >= limit) {
break;
}
}
}
return count;
}
function regionNode(id: string, title: string, value: unknown, path: string): EpiphanyGraphNode {
return {
id,
title,
purpose: valuePurpose(value),
mechanism: path,
status: `${valueStatus(value)} / ${countPayloadDescendants(value)} values`,
};
}
function recordNodeId(record: InspectedRecord, index: number): string {
return nodeId("record", `${record.schemaId}:${record.key}:${index}`);
}
function valueEdge(sourceId: string, targetId: string, kind: string, label: string): EpiphanyGraphEdge {
return {
id: edgeId(sourceId, targetId, kind),
source_id: sourceId,
target_id: targetId,
kind,
label,
};
}
function valuePurpose(value: unknown): string {
if (Array.isArray(value)) {
return `Array with ${value.length} item(s).`;
}
if (isPlainObject(value)) {
return `Object with ${Object.keys(value).length} field(s).`;
}
if (value === null) {
return "Null payload value.";
}
return JSON.stringify(value) ?? String(value);
}
function valueStatus(value: unknown): string {
if (Array.isArray(value)) {
return "array";
}
if (isPlainObject(value)) {
return "object";
}
return typeof value;
}
function emptyGraphProjection(): GraphProjection {
return {
state: {
architecture: { nodes: [], edges: [] },
dataflow: { nodes: [], edges: [] },
links: [],
},
recordNodeIds: [],
nodeSelections: new Map(),
edgeSelections: new Map(),
collapsedPayloadValues: 0,
};
}
function parseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
function nodeId(prefix: string, value: string): string {
return `${prefix}:${value.replace(/[^a-zA-Z0-9:_-]/g, "_")}`;
}
function edgeId(sourceId: string, targetId: string, kind: string): string {
return `${sourceId}->${targetId}:${kind}`;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,726 +0,0 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #dce9ee;
background: #05090d;
--bg: #05090d;
--panel: #0b131b;
--panel-strong: #101c27;
--ink: #dce9ee;
--muted: #8296a3;
--line: #1e3444;
--line-bright: #1e7fa4;
--cyan: #33e6f3;
--cyan-soft: rgba(51, 230, 243, 0.16);
--cyan-glow: rgba(51, 230, 243, 0.36);
--brass: #d49a3d;
--danger: #ff897d;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 760px;
min-height: 100vh;
overflow: hidden;
}
button,
input {
font: inherit;
}
code {
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 0.92em;
}
.workspace {
position: relative;
display: block;
min-height: 100vh;
background:
radial-gradient(circle at 78% 18%, rgba(39, 202, 239, 0.12), transparent 30%),
linear-gradient(135deg, #05090d 0%, #07111a 48%, #030507 100%);
}
.sidebar {
border-right: 1px solid var(--line);
background: linear-gradient(180deg, rgba(14, 27, 38, 0.98), rgba(5, 9, 13, 0.98));
padding: 22px;
box-shadow: 8px 0 36px rgba(0, 0, 0, 0.32);
}
.brand {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: 14px;
align-items: center;
}
.brand img {
border-radius: 8px;
image-rendering: pixelated;
filter: drop-shadow(0 0 14px var(--cyan-glow));
}
.brand h1 {
margin: 0;
color: #f0fbff;
font-size: 30px;
line-height: 1.05;
letter-spacing: 0;
}
.brand p {
margin: 7px 0 18px;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.dropzone {
display: grid;
gap: 6px;
place-items: center;
min-height: 132px;
border: 1px dashed #276982;
border-radius: 8px;
background: linear-gradient(180deg, rgba(13, 38, 50, 0.72), rgba(6, 15, 22, 0.88));
color: var(--cyan);
cursor: pointer;
text-align: center;
box-shadow: inset 0 0 18px rgba(51, 230, 243, 0.06);
}
.is-dragging .dropzone {
border-color: var(--cyan);
background: rgba(51, 230, 243, 0.12);
box-shadow: 0 0 22px var(--cyan-glow), inset 0 0 22px rgba(51, 230, 243, 0.1);
}
.dropzone input {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.drop-title {
font-weight: 800;
}
.drop-copy {
color: var(--muted);
font-size: 13px;
}
.summary {
display: grid;
gap: 10px;
margin-top: 16px;
}
.metric {
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(12, 23, 32, 0.92);
padding: 11px;
}
.metric span {
display: block;
color: var(--muted);
font-size: 12px;
}
.metric strong {
display: block;
margin-top: 4px;
overflow-wrap: anywhere;
}
.content {
padding: 18px;
overflow: auto;
}
.empty-workspace,
.inspector-stage {
position: fixed;
inset: 0;
overflow: hidden;
background: #03070a;
}
.empty-workspace {
display: grid;
place-items: center;
padding: clamp(18px, 4vw, 56px);
}
.empty-workspace .huginn-field-canvas {
opacity: 0.46;
}
.overlay-layer {
position: fixed;
inset: 0;
z-index: 12;
pointer-events: none;
}
.graph-panel {
position: fixed;
inset: 0;
min-width: 0;
border: 0;
border-radius: 0;
overflow: hidden;
background: #03070a;
}
.graph-panel > section {
width: 100vw !important;
height: 100vh !important;
min-height: 100vh !important;
}
.huginn-field-canvas {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
image-rendering: auto;
opacity: 0.96;
pointer-events: none;
}
.epiphany-graph-overlay-panel {
background: rgba(4, 11, 16, 0.72) !important;
border-color: rgba(51, 230, 243, 0.22) !important;
box-shadow: 0 0 28px rgba(51, 230, 243, 0.08), 0 18px 48px rgba(0, 0, 0, 0.28) !important;
}
.huginn-graph-shell {
width: 100vw;
height: 100vh;
}
.huginn-graph-shell svg {
filter: drop-shadow(0 0 12px rgba(51, 230, 243, 0.28));
}
.huginn-graph-shell svg path[stroke]:not([stroke="transparent"]) {
opacity: 1;
stroke-width: 2.4px;
}
.huginn-graph-shell svg rect[filter] {
fill-opacity: 0.94;
stroke-opacity: 1;
}
.huginn-graph-shell svg text {
fill-opacity: 1;
paint-order: stroke;
stroke: rgba(1, 6, 10, 0.72);
stroke-width: 2px;
stroke-linejoin: round;
}
.huginn-graph-shell .epiphany-graph-overlay-panel,
.floating-panel {
opacity: 0.28;
transition:
opacity 160ms ease,
transform 160ms ease,
box-shadow 160ms ease,
background-color 160ms ease;
}
.huginn-graph-shell .epiphany-graph-overlay-panel:hover,
.huginn-graph-shell .epiphany-graph-overlay-panel:focus-within,
.floating-panel:hover,
.floating-panel:focus-within,
.floating-panel.is-primary {
opacity: 1;
transform: translateY(-2px);
}
.floating-panel {
position: absolute;
pointer-events: auto;
container-type: inline-size;
border: 1px solid rgba(51, 230, 243, 0.22);
border-radius: 24px;
background: rgba(4, 11, 16, 0.72);
padding: clamp(10px, 1.4vw, 18px);
box-shadow: 0 20px 54px rgba(0, 0, 0, 0.34), 0 0 28px rgba(51, 230, 243, 0.08);
backdrop-filter: blur(16px);
}
.brand-panel {
top: 16px;
left: 16px;
width: min(330px, calc(100vw - 32px));
transform: translateX(-242px);
}
.brand-panel:hover,
.brand-panel:focus-within,
.brand-panel.is-primary {
transform: translateX(0);
}
.records-panel {
left: 16px;
bottom: 16px;
width: min(360px, calc(50vw - 24px));
max-height: min(44vh, 420px);
transform: translateY(calc(100% - 48px));
}
.records-panel:hover,
.records-panel:focus-within {
transform: translate(0, 0);
}
.graph-warning {
position: fixed;
left: 50%;
bottom: 18px;
z-index: 14;
max-width: min(720px, calc(100vw - 36px));
transform: translateX(-50%);
border-top: 1px solid rgba(212, 154, 61, 0.3);
border: 1px solid rgba(212, 154, 61, 0.3);
border-radius: 18px;
background: rgba(72, 45, 12, 0.34);
color: #ffd38a;
padding: 10px 12px;
font-size: 12px;
line-height: 1.35;
}
.hero {
position: relative;
z-index: 2;
display: grid;
align-content: center;
justify-items: center;
min-height: calc(100vh - 36px);
max-width: 680px;
text-align: center;
}
.hero img {
width: 168px;
height: 168px;
margin-bottom: 18px;
image-rendering: auto;
filter: drop-shadow(0 0 28px var(--cyan-glow));
}
.hero h2 {
margin: 0;
color: #f0fbff;
font-size: 42px;
line-height: 1;
text-shadow: 0 0 18px rgba(3, 7, 10, 0.92), 0 0 34px rgba(51, 230, 243, 0.28);
}
.hero p {
margin: 14px 0 0;
color: var(--muted);
line-height: 1.55;
text-shadow: 0 0 14px rgba(3, 7, 10, 0.96);
}
.panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(8, 16, 23, 0.94);
overflow: hidden;
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.24);
}
.panel header,
.floating-panel header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
border-bottom: 1px solid var(--line);
padding: 11px 12px;
background: rgba(16, 28, 39, 0.92);
}
.panel h2,
.floating-panel h2 {
margin: 0;
color: #f0fbff;
font-size: 15px;
}
.panel header span,
.floating-panel header span {
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.list {
display: grid;
gap: 8px;
max-height: inherit;
overflow: auto;
padding: 10px;
}
.row {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-strong);
color: inherit;
padding: 10px;
text-align: left;
cursor: pointer;
}
.row[aria-selected="true"] {
border-color: var(--cyan);
box-shadow: inset 3px 0 0 var(--cyan), 0 0 18px rgba(51, 230, 243, 0.12);
}
.row strong,
.row span {
display: block;
overflow-wrap: anywhere;
}
.row span {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
dl {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 8px 12px;
}
dt {
color: var(--muted);
font-size: 12px;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
pre {
max-height: min(440px, 42vh);
overflow: auto;
border-radius: 6px;
border: 1px solid #142b39;
background: #03080c;
color: #dffbff;
padding: 12px;
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
line-height: 1.45;
}
.error {
border: 1px solid rgba(255, 137, 125, 0.42);
border-radius: 6px;
background: rgba(94, 27, 24, 0.28);
color: var(--danger);
padding: 10px;
overflow-wrap: anywhere;
}
.fatal-error {
display: grid;
gap: 8px;
max-width: 720px;
margin: 32px;
padding: 16px;
}
.empty {
padding: 18px;
color: var(--muted);
}
.huginn-expanded-node {
scrollbar-color: rgba(51, 230, 243, 0.58) rgba(3, 7, 10, 0.32);
transition:
border-radius 180ms ease,
box-shadow 180ms ease,
padding 180ms ease;
}
.expanded-content {
display: grid;
gap: clamp(10px, 1.4vw, 18px);
min-height: 100%;
padding: clamp(14px, 2.2vw, 30px);
color: #e7fbff;
}
.expanded-kicker {
color: var(--cyan);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.expanded-header {
display: grid;
gap: 10px;
}
.expanded-header h1 {
margin: 0;
overflow-wrap: anywhere;
color: #f8fdff;
font-size: clamp(1.45rem, 3.4vw, 3rem);
line-height: 1;
}
.expanded-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.expanded-chips span,
.expanded-footer {
border: 1px solid rgba(51, 230, 243, 0.22);
border-radius: 999px;
background: rgba(5, 18, 26, 0.68);
color: #aeeef5;
padding: 6px 9px;
font-size: 12px;
font-weight: 700;
}
.expanded-summary {
color: rgba(231, 251, 255, 0.78);
line-height: 1.55;
}
.expanded-payload {
display: grid;
gap: 8px;
min-width: 0;
min-height: 0;
}
.expanded-payload h2 {
margin: 0;
color: #f8fdff;
font-size: 18px;
}
.expanded-article {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-height: 0;
}
.expanded-article h2 {
margin: 0 0 8px;
color: #f8fdff;
font-size: 18px;
}
.expanded-footer {
width: fit-content;
}
.huginn-expanded-node[data-node-stage="summary"] {
overflow: hidden;
}
.huginn-expanded-node[data-node-stage="summary"] .expanded-content {
display: grid;
align-items: center;
justify-items: center;
gap: 6px;
padding: 10px 14px;
text-align: center;
}
.huginn-expanded-node[data-node-stage="summary"] .expanded-kicker,
.huginn-expanded-node[data-node-stage="summary"] .expanded-summary,
.huginn-expanded-node[data-node-stage="summary"] .expanded-article,
.huginn-expanded-node[data-node-stage="summary"] .expanded-footer {
display: none;
}
.huginn-expanded-node[data-node-stage="summary"] .expanded-header {
display: grid;
justify-items: center;
min-width: 0;
}
.huginn-expanded-node[data-node-stage="summary"] .expanded-header h1 {
max-width: 100%;
font-size: 1.02rem;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.huginn-expanded-node[data-node-stage="summary"] .expanded-chips {
justify-content: center;
}
.huginn-expanded-node[data-node-stage="summary"] .expanded-payload {
display: none;
}
.huginn-expanded-node[data-node-stage="preview"] {
overflow: hidden;
}
.huginn-expanded-node[data-node-stage="preview"] .expanded-content {
align-content: center;
justify-items: center;
gap: 10px;
padding: 18px 22px;
text-align: center;
}
.huginn-expanded-node[data-node-stage="preview"] .expanded-header {
justify-items: center;
max-width: min(640px, 100%);
}
.huginn-expanded-node[data-node-stage="preview"] .expanded-header h1 {
max-width: 100%;
font-size: clamp(1.05rem, 2.1vw, 1.7rem);
text-align: center;
}
.huginn-expanded-node[data-node-stage="preview"] .expanded-chips {
justify-content: center;
}
.huginn-expanded-node[data-node-stage="preview"] .expanded-summary,
.huginn-expanded-node[data-node-stage="preview"] .expanded-article,
.huginn-expanded-node[data-node-stage="preview"] .expanded-footer {
display: none;
}
.huginn-expanded-node[data-node-stage="preview"] .expanded-payload {
width: min(680px, 100%);
justify-items: center;
}
.huginn-expanded-node[data-node-stage="preview"] .expanded-payload h2 {
color: var(--cyan);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.huginn-expanded-node[data-node-stage="preview"] .payload-code {
width: 100%;
max-height: min(280px, 44vh);
margin: 0;
text-align: left;
}
.huginn-expanded-node[data-node-stage="preview"] dl {
display: none;
}
.huginn-expanded-node[data-node-stage="article"] {
overflow: auto;
}
@container (max-width: 260px) {
.brand p,
.drop-copy,
.metric span,
.row span,
.floating-panel header span {
display: none;
}
.brand {
grid-template-columns: 42px minmax(0, 1fr);
}
.brand img {
width: 42px;
height: 42px;
}
.brand h1 {
font-size: 22px;
}
.dropzone {
min-height: 54px;
}
}
@container (max-width: 180px) {
.summary,
.dropzone,
.list {
display: none;
}
.floating-panel header {
border-bottom: 0;
padding: 6px 8px;
}
}
@media (max-width: 900px) {
body {
min-width: 320px;
}
.brand-panel {
width: min(300px, calc(100vw - 24px));
transform: translateX(calc(-100% + 58px));
}
.records-panel {
left: 12px;
right: 12px;
width: auto;
max-height: 32vh;
}
.records-panel {
bottom: 12px;
}
.expanded-article {
grid-template-columns: 1fr;
}
}

4884
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,19 @@
{
"name": "cultcache-ts",
"name": "@gamecult/huginn",
"version": "0.13.5",
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
"description": "Huginn .cc inspection and Eve projection surface for CultCache state.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"huginn": "dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"build:inspector": "tsc -p tsconfig.inspector.json && vite build --config vite.inspector.config.ts",
"dev:inspector": "concurrently -k \"vite --host 127.0.0.1 --config vite.inspector.config.ts\" \"wait-on http://127.0.0.1:5173 && cross-env CULTCACHE_INSPECTOR_DEV_SERVER=http://127.0.0.1:5173 electron electron/main.cjs\"",
"dist:inspector": "npm run build:inspector && electron-builder --config electron-builder.json",
"pack:inspector": "npm run build:inspector && electron-builder --config electron-builder.json --dir",
"typecheck": "tsc -p tsconfig.json --pretty false",
"test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js"
"test": "npm run build"
},
"keywords": [
"messagepack",
@ -27,23 +26,10 @@
"author": "metacratic",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"zod": "^3.25.76"
"cultcache-ts": "file:../CultLib/packages/cultcache-ts"
},
"devDependencies": {
"@epiphanygraph/epiphany-graph-viewer": "file:../EpiphanyGraph/web/epiphany-graph-viewer",
"@types/node": "^24.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^42.1.0",
"electron-builder": "^26.8.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"typescript": "^5.9.0",
"vite": "^8.0.13",
"wait-on": "^9.0.10"
"typescript": "^5.9.0"
}
}

23
src/cli.ts Normal file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { inspectCultCacheBytes } from "cultcache-ts/inspection";
import { buildHuginnEveDsl } from "./huginn-eve-dsl";
async function main(args: string[]): Promise<void> {
const filePath = args[0];
if (!filePath || args.includes("--help") || args.includes("-h")) {
process.stderr.write("Usage: huginn <state.cc|state.msgpack|state.mpack>\n");
process.exitCode = filePath ? 0 : 1;
return;
}
const bytes = await readFile(filePath);
const inspection = inspectCultCacheBytes(filePath, bytes);
process.stdout.write(`${buildHuginnEveDsl(inspection)}\n`);
}
void main(process.argv.slice(2)).catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});

View file

@ -1,305 +0,0 @@
import { decode } from "@msgpack/msgpack";
export interface CultCacheInspection {
filePath: string;
fileSizeBytes: number;
format: "cultcache.store.v1" | "legacy.envelope-array";
catalog: InspectedCatalogEntry[];
records: InspectedRecord[];
}
export interface InspectedCatalogEntry {
schemaId: string;
schemaName: string;
schemaVersion: string;
contentHash: string;
canonicalSchemaJson: string;
compatibleSchemaIds: string[];
members: InspectedCatalogMember[];
}
export interface InspectedCatalogMember {
slot: number;
memberName: string;
typeName: string;
isReference: boolean;
isMany: boolean;
targetSchemaName: string | null;
isName: boolean;
indexAlias: string | null;
}
export interface InspectedRecord {
key: string;
schemaId: string;
schemaName: string;
storedAt: string;
payloadBytes: number;
payloadPreview: unknown;
payloadDecodeError?: string;
}
const STORE_FORMAT_VERSION = "cultcache.store.v1";
export function inspectCultCacheBytes(
filePath: string,
bytes: Uint8Array,
fileSizeBytes = bytes.length,
): CultCacheInspection {
const decoded = decode(bytes);
if (isV1Snapshot(decoded)) {
return inspectV1Snapshot(filePath, fileSizeBytes, decoded);
}
if (Array.isArray(decoded)) {
return inspectLegacyEnvelopeArray(filePath, fileSizeBytes, decoded);
}
throw new Error("CultCache file is not a recognized v1 snapshot or legacy envelope array.");
}
function inspectV1Snapshot(filePath: string, fileSizeBytes: number, decoded: unknown[]): CultCacheInspection {
const catalogRaw = decoded[1];
const recordsRaw = decoded[2];
if (!Array.isArray(catalogRaw) || !Array.isArray(recordsRaw)) {
throw new Error("CultCache v1 snapshot must contain a schema catalog and record array.");
}
const catalog = catalogRaw.map(decodeCatalogEntry);
const catalogBySchemaId = new Map<string, InspectedCatalogEntry>();
for (const entry of catalog) {
catalogBySchemaId.set(entry.schemaId, entry);
for (const compatibleSchemaId of entry.compatibleSchemaIds) {
catalogBySchemaId.set(compatibleSchemaId, entry);
}
}
return {
filePath,
fileSizeBytes,
format: STORE_FORMAT_VERSION,
catalog,
records: recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)),
};
}
function inspectLegacyEnvelopeArray(filePath: string, fileSizeBytes: number, decoded: unknown[]): CultCacheInspection {
const records = decoded.map((entry) => decodeLegacyEnvelope(entry));
const schemaNames = new Set(records.map((record) => record.schemaName));
const catalog = [...schemaNames].sort(compareOrdinal).map((schemaName) => ({
schemaId: schemaName,
schemaName,
schemaVersion: `${schemaName}.v1`,
contentHash: schemaName,
canonicalSchemaJson: JSON.stringify({
schemaName,
schemaVersion: `${schemaName}.v1`,
members: [],
}),
compatibleSchemaIds: [schemaName],
members: [],
}));
return {
filePath,
fileSizeBytes,
format: "legacy.envelope-array",
catalog,
records,
};
}
function decodeCatalogEntry(value: unknown): InspectedCatalogEntry {
if (!Array.isArray(value)) {
throw new Error("CultCache schema catalog entries must be MessagePack arrays.");
}
const [
schemaId = "",
schemaName = "",
schemaVersion = "",
contentHash = "",
canonicalSchemaJson = "",
compatibleSchemaIds = [],
members = [],
] = value;
if (!isNonEmptyString(schemaId) || !isNonEmptyString(schemaName)) {
throw new Error("CultCache schema catalog entries must declare schemaId and schemaName.");
}
return {
schemaId,
schemaName,
schemaVersion: isNonEmptyString(schemaVersion) ? schemaVersion : `${schemaName}.v1`,
contentHash: isNonEmptyString(contentHash) ? contentHash : schemaId,
canonicalSchemaJson: typeof canonicalSchemaJson === "string" ? canonicalSchemaJson : "",
compatibleSchemaIds: Array.isArray(compatibleSchemaIds)
? compatibleSchemaIds.filter(isNonEmptyString)
: [],
members: Array.isArray(members) ? members.map(decodeCatalogMember) : [],
};
}
function decodeCatalogMember(value: unknown): InspectedCatalogMember {
if (!Array.isArray(value)) {
throw new Error("CultCache schema catalog members must be MessagePack arrays.");
}
const [
slot = -1,
memberName = "",
typeName = "",
isReference = false,
isMany = false,
targetSchemaName = null,
isName = false,
indexAlias = null,
] = value;
if (!Number.isInteger(slot) || !isNonEmptyString(memberName) || !isNonEmptyString(typeName)) {
throw new Error("CultCache schema catalog member has invalid slot, name, or type.");
}
return {
slot,
memberName,
typeName,
isReference: isReference === true,
isMany: isMany === true,
targetSchemaName: typeof targetSchemaName === "string" ? targetSchemaName : null,
isName: isName === true,
indexAlias: typeof indexAlias === "string" ? indexAlias : null,
};
}
function decodeV1Record(value: unknown, catalogBySchemaId: Map<string, InspectedCatalogEntry>): InspectedRecord {
if (!Array.isArray(value)) {
throw new Error("CultCache persisted records must be MessagePack arrays.");
}
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
}
const catalogEntry = catalogBySchemaId.get(schemaId);
const payloadBytes = normalizePayloadBytes(payload);
return {
key,
schemaId,
schemaName: catalogEntry?.schemaName ?? "<missing catalog entry>",
storedAt,
payloadBytes: payloadBytes.length,
...previewPayload(payloadBytes),
};
}
function decodeLegacyEnvelope(value: unknown): InspectedRecord {
if (!isRecord(value)) {
throw new Error("CultCache legacy envelopes must be MessagePack maps.");
}
const key = value.key;
const schemaName = value.type;
const storedAt = value.storedAt;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaName) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache legacy envelopes must declare key, type, and storedAt.");
}
const schemaId = isNonEmptyString(value.schemaId) ? value.schemaId : schemaName;
const payloadBytes = normalizePayloadBytes(value.payload);
return {
key,
schemaId,
schemaName,
storedAt,
payloadBytes: payloadBytes.length,
...previewPayload(payloadBytes),
};
}
function previewPayload(payload: Uint8Array): Pick<InspectedRecord, "payloadPreview" | "payloadDecodeError"> {
try {
return { payloadPreview: toJsonSafe(decode(payload)) };
} catch (error) {
return {
payloadPreview: {
bytes: payload.length,
hexPrefix: [...payload.slice(0, 32)]
.map((value) => value.toString(16).padStart(2, "0"))
.join(" "),
},
payloadDecodeError: error instanceof Error ? error.message : String(error),
};
}
}
function normalizePayloadBytes(value: unknown): Uint8Array {
if (value instanceof Uint8Array) {
return value;
}
if (
isRecord(value) &&
value.type === "Buffer" &&
Array.isArray(value.data) &&
value.data.every(isByte)
) {
return Uint8Array.from(value.data);
}
if (Array.isArray(value) && value.every(isByte)) {
return Uint8Array.from(value);
}
throw new Error("CultCache record payload must be binary MessagePack bytes.");
}
function toJsonSafe(value: unknown): unknown {
if (typeof value === "bigint") {
return value.toString();
}
if (value instanceof Uint8Array) {
return {
bytes: value.length,
hexPrefix: [...value.slice(0, 32)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join(" "),
};
}
if (Array.isArray(value)) {
return value.map(toJsonSafe);
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [key, toJsonSafe(entryValue)]),
);
}
return value;
}
function isV1Snapshot(value: unknown): value is unknown[] {
return Array.isArray(value) && value[0] === STORE_FORMAT_VERSION;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function isByte(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255;
}
function compareOrdinal(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}

View file

@ -1,871 +0,0 @@
import { decode, encode } from "@msgpack/msgpack";
import type {
AnyCultCacheDocumentDefinition,
CacheBackingStore,
CultCacheDocumentAccessor,
CultCacheDocumentDefinition,
CultCacheDocumentFormatter,
CultCacheDocumentRegistry,
CultCacheDocumentValue,
CultCacheEnvelope,
CultCacheSchemaCatalogEntry,
CultCacheStoreRegistration,
} from "./types";
type StoreRoute = {
primary?: CacheBackingStore;
mirrors: CacheBackingStore[];
};
type RegisteredDefinition = {
readonly definition: AnyCultCacheDocumentDefinition;
readonly global: boolean;
readonly formatter: CultCacheDocumentFormatter<unknown>;
readonly catalogEntry: CultCacheSchemaCatalogEntry;
nameAccessor?: (value: unknown) => string | undefined;
readonly indexAccessors: Map<string, (value: unknown) => string | undefined>;
};
type HydratedEntry = CultCacheEnvelope & {
value: unknown;
};
type BackingStoreTypeReference = string | AnyCultCacheDocumentDefinition;
type AccessorInput = string | ((value: any) => unknown);
export class CultCacheBuilder {
readonly #cache = new CultCache();
withDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): this {
this.#cache.registerDocumentType(definition);
return this;
}
withRegistry(
registry:
| CultCacheDocumentRegistry
| Iterable<AnyCultCacheDocumentDefinition>,
): this {
this.#cache.registerRegistry(registry);
return this;
}
withBackingStore(
store: CacheBackingStore,
...types: BackingStoreTypeReference[]
): this {
this.#cache.addBackingStore(store, ...types);
return this;
}
withGenericStore(store: CacheBackingStore): this {
this.#cache.addGenericBackingStore(store);
return this;
}
build(): CultCache {
return this.#cache;
}
}
export class CultCache {
static readonly GLOBAL_KEY = "__global__";
readonly #definitions = new Map<string, RegisteredDefinition>();
readonly #schemaIdDefinitions = new Map<string, RegisteredDefinition>();
readonly #entries = new Map<string, HydratedEntry>();
readonly #typeEntryIds = new Map<string, Set<string>>();
readonly #nameToKeyMaps = new Map<string, Map<string, string>>();
readonly #indexToKeyMaps = new Map<string, Map<string, string>>();
readonly #globalKeys = new Map<string, string>();
readonly #stores: CultCacheStoreRegistration[] = [];
static builder(): CultCacheBuilder {
return new CultCacheBuilder();
}
registerDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): TDefinition {
const existing = this.#definitions.get(definition.type);
if (existing && existing.definition !== definition) {
throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`);
}
const registered = existing ?? {
definition,
global: definition.global === true,
formatter: this.#createFormatter(definition),
catalogEntry: this.#createCatalogEntry(definition),
nameAccessor: undefined,
indexAccessors: new Map<string, (value: unknown) => string | undefined>(),
};
registered.nameAccessor = definition.name
? this.#compileAccessor(definition.type, "name", definition.name)
: undefined;
registered.indexAccessors.clear();
for (const [indexName, accessor] of this.#enumerateDefinitionIndexes(definition)) {
registered.indexAccessors.set(
indexName,
this.#compileAccessor(definition.type, `index "${indexName}"`, accessor),
);
}
this.#definitions.set(definition.type, registered);
for (const schemaId of registered.catalogEntry.compatibleSchemaIds ?? [registered.catalogEntry.schemaId]) {
const existingBySchema = this.#schemaIdDefinitions.get(schemaId);
if (existingBySchema && existingBySchema !== registered) {
throw new Error(`CultCache schema id "${schemaId}" is already registered for type "${existingBySchema.definition.type}".`);
}
this.#schemaIdDefinitions.set(schemaId, registered);
}
this.#rebuildDefinitionLookups(registered);
return definition;
}
registerRegistry(
registry:
| CultCacheDocumentRegistry
| Iterable<AnyCultCacheDocumentDefinition>,
): this {
const definitions = Symbol.iterator in Object(registry) && !("definitions" in Object(registry))
? registry as Iterable<AnyCultCacheDocumentDefinition>
: (registry as CultCacheDocumentRegistry).definitions;
for (const definition of definitions) {
this.registerDocumentType(definition);
}
return this;
}
registerNameLookup<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>,
): TDefinition {
const registered = this.#requireDefinition(definition);
registered.nameAccessor = this.#compileAccessor(definition.type, "name", accessor);
this.#rebuildNameLookup(registered);
return definition;
}
registerIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
indexName: string,
accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>,
): TDefinition {
if (!indexName || indexName.trim().length === 0) {
throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`);
}
const registered = this.#requireDefinition(definition);
registered.indexAccessors.set(
indexName,
this.#compileAccessor(definition.type, `index "${indexName}"`, accessor),
);
this.#rebuildIndexLookup(registered, indexName);
return definition;
}
addBackingStore(
store: CacheBackingStore,
...types: BackingStoreTypeReference[]
): void {
this.#stores.push({
store,
types: [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))],
});
}
addGenericBackingStore(store: CacheBackingStore): void {
this.addBackingStore(store);
}
async pullAllBackingStores(): Promise<void> {
this.#resetHydratedState();
for (const registration of this.#stores) {
const entries = await registration.store.pullAll();
for (const entry of entries) {
const registered = this.#resolveDefinitionForEnvelope(entry);
if (!registered) {
throw new Error(
entry.schemaId
? `No schema is registered for persisted schema id "${entry.schemaId}".`
: `No schema is registered for persisted document type "${entry.type}".`,
);
}
const payload = this.#cloneBytes(entry.payload);
const value = registered.formatter.decode(payload);
this.#applyHydratedEntry(registered, {
...entry,
payload,
}, value, "pull");
}
}
}
get<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
): CultCacheDocumentValue<TDefinition> | undefined {
this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
return entry ? (entry.value as CultCacheDocumentValue<TDefinition>) : undefined;
}
getRequired<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
): CultCacheDocumentValue<TDefinition> {
const value = this.get(definition, key);
if (value === undefined) {
throw new Error(`CultCache has no "${definition.type}" document at key "${key}".`);
}
return value;
}
getEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
): CultCacheEnvelope | undefined {
this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
return entry ? this.#toEnvelope(entry) : undefined;
}
getRequiredEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
): CultCacheEnvelope {
const entry = this.getEnvelope(definition, key);
if (!entry) {
throw new Error(`CultCache has no "${definition.type}" envelope at key "${key}".`);
}
return entry;
}
getGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): CultCacheDocumentValue<TDefinition> | undefined {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
const globalKey = this.#globalKeys.get(definition.type);
return globalKey ? this.get(definition, globalKey) : undefined;
}
getRequiredGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): CultCacheDocumentValue<TDefinition> {
const value = this.getGlobal(definition);
if (value === undefined) {
throw new Error(`CultCache has no global "${definition.type}" document.`);
}
return value;
}
getGlobalEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): CultCacheEnvelope | undefined {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
const globalKey = this.#globalKeys.get(definition.type);
return globalKey ? this.getEnvelope(definition, globalKey) : undefined;
}
getAll<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): CultCacheDocumentValue<TDefinition>[] {
this.#requireDefinition(definition);
const values: CultCacheDocumentValue<TDefinition>[] = [];
const entryIds = this.#typeEntryIds.get(definition.type);
if (!entryIds) {
return values;
}
for (const entryId of entryIds) {
const entry = this.#entries.get(entryId);
if (entry) {
values.push(entry.value as CultCacheDocumentValue<TDefinition>);
}
}
return values;
}
getKeyByName<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
name: string,
): string | undefined {
const registered = this.#requireDefinition(definition);
if (!registered.nameAccessor) {
return undefined;
}
return this.#nameToKeyMaps.get(definition.type)?.get(name);
}
getIdByName<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
name: string,
): string | undefined {
return this.getKeyByName(definition, name);
}
getByName<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
name: string,
): CultCacheDocumentValue<TDefinition> | undefined {
const key = this.getKeyByName(definition, name);
return key ? this.get(definition, key) : undefined;
}
getKeyByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
indexName: string,
value: string,
): string | undefined {
this.#requireDefinition(definition);
return this.#indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value);
}
getIdByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
indexName: string,
value: string,
): string | undefined {
return this.getKeyByIndex(definition, indexName, value);
}
getByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
indexName: string,
value: string,
): CultCacheDocumentValue<TDefinition> | undefined {
const key = this.getKeyByIndex(definition, indexName, value);
return key ? this.get(definition, key) : undefined;
}
async put<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
value: CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
const registered = this.#requireDefinition(definition);
const payload = registered.formatter.encode(value);
const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue<TDefinition>;
const entry: CultCacheEnvelope = {
key,
type: definition.type,
payload: this.#cloneBytes(payload),
storedAt: new Date().toISOString(),
schemaId: registered.catalogEntry.schemaId,
catalogEntry: registered.catalogEntry,
};
const route = this.#resolveRoute(definition.type);
if (!route.primary) {
throw new Error(`No backing store is registered for document type "${definition.type}".`);
}
if (registered.global) {
const existingGlobalKey = this.#globalKeys.get(definition.type);
if (existingGlobalKey && existingGlobalKey !== key) {
await this.delete(definition, existingGlobalKey);
}
}
await route.primary.push(entry);
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
this.#applyHydratedEntry(registered, entry, parsed, "put");
return parsed;
}
async putEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
envelope: CultCacheEnvelope,
): Promise<CultCacheDocumentValue<TDefinition>> {
const registered = this.#requireDefinition(definition);
if (envelope.type !== definition.type) {
throw new Error(
`CultCache envelope type "${envelope.type}" does not match definition "${definition.type}".`,
);
}
if (!envelope.key || envelope.key.trim().length === 0) {
throw new Error(`CultCache envelope key for type "${definition.type}" must be non-empty.`);
}
if (!envelope.storedAt || envelope.storedAt.trim().length === 0) {
throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`);
}
const payload = this.#cloneBytes(envelope.payload);
const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue<TDefinition>;
const entry: CultCacheEnvelope = {
key: envelope.key,
type: envelope.type,
payload,
storedAt: envelope.storedAt,
schemaId: envelope.schemaId ?? registered.catalogEntry.schemaId,
catalogEntry: envelope.catalogEntry ?? registered.catalogEntry,
};
const route = this.#resolveRoute(definition.type);
if (!route.primary) {
throw new Error(`No backing store is registered for document type "${definition.type}".`);
}
if (registered.global) {
const existingGlobalKey = this.#globalKeys.get(definition.type);
if (existingGlobalKey && existingGlobalKey !== entry.key) {
await this.delete(definition, existingGlobalKey);
}
}
await route.primary.push(entry);
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
this.#applyHydratedEntry(registered, entry, parsed, "put");
return parsed;
}
async putGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
value: CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
return this.put(definition, CultCache.GLOBAL_KEY, value);
}
async update<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
updater: (
current: CultCacheDocumentValue<TDefinition> | undefined,
) => CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
const current = this.get(definition, key);
return this.put(definition, key, updater(current));
}
async updateGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
updater: (
current: CultCacheDocumentValue<TDefinition> | undefined,
) => CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
const current = this.getGlobal(definition);
return this.putGlobal(definition, updater(current));
}
async delete<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
): Promise<boolean> {
const registered = this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
if (!entry) {
return false;
}
const route = this.#resolveRoute(definition.type);
if (!route.primary) {
throw new Error(`No backing store is registered for document type "${definition.type}".`);
}
const envelope = this.#toEnvelope(entry);
await route.primary.delete(envelope);
await Promise.all(route.mirrors.map(async (mirror) => mirror.delete(envelope)));
this.#removeHydratedEntry(registered, entry);
return true;
}
async deleteGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): Promise<boolean> {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
const globalKey = this.#globalKeys.get(definition.type);
return globalKey ? this.delete(definition, globalKey) : false;
}
snapshot(): CultCacheEnvelope[] {
return [...this.#entries.values()].map((entry) => this.#toEnvelope(entry));
}
#createFormatter(definition: AnyCultCacheDocumentDefinition): CultCacheDocumentFormatter<unknown> {
const userFormatter = definition.formatter ?? {
encode: (value: unknown) => encode(value),
decode: (payload: Uint8Array) => decode(payload),
};
return {
encode: (value: unknown) => {
const parsed = definition.schema.parse(value);
return this.#cloneBytes(userFormatter.encode(parsed));
},
decode: (payload: Uint8Array) => {
const decoded = userFormatter.decode(this.#cloneBytes(payload));
return definition.schema.parse(decoded);
},
};
}
#createCatalogEntry(definition: AnyCultCacheDocumentDefinition): CultCacheSchemaCatalogEntry {
const schemaName = definition.schemaName ?? definition.type;
const schemaVersion = definition.schemaVersion ?? `${schemaName}.v1`;
const canonicalSchemaJson = definition.canonicalSchemaJson ?? JSON.stringify({
schemaName,
schemaVersion,
members: [...(definition.members ?? [])]
.sort((left, right) => left.slot - right.slot)
.map((member) => ({
slot: member.slot,
name: member.memberName,
type: member.typeName,
isReference: member.isReference === true,
many: member.isMany === true,
targetSchemaName: member.targetSchemaName ?? null,
indexAlias: member.indexAlias ?? null,
isName: member.isName === true,
})),
});
const schemaId = definition.schemaId ?? schemaName;
const compatibleSchemaIds = [
...new Set([schemaId, ...(definition.compatibleSchemaIds ?? [])]),
];
return {
schemaId,
schemaName,
schemaVersion,
contentHash: definition.contentHash ?? schemaId,
canonicalSchemaJson,
compatibleSchemaIds,
members: [...(definition.members ?? [])]
.sort((left, right) => left.slot - right.slot)
.map((member) => ({
slot: member.slot,
memberName: member.memberName,
typeName: member.typeName,
isReference: member.isReference === true,
isMany: member.isMany === true,
targetSchemaName: member.targetSchemaName ?? null,
isName: member.isName === true,
indexAlias: member.indexAlias ?? null,
})),
};
}
#enumerateDefinitionIndexes(
definition: AnyCultCacheDocumentDefinition,
): Array<[string, AccessorInput]> {
if (!definition.indexes) {
return [];
}
if (Array.isArray(definition.indexes)) {
return definition.indexes.map((index) => [index.name, index.accessor as AccessorInput]);
}
return Object.entries(definition.indexes) as Array<[string, AccessorInput]>;
}
#compileAccessor(
type: string,
label: string,
accessor: AccessorInput,
): (value: unknown) => string | undefined {
if (typeof accessor === "function") {
return (value: unknown) => this.#normalizeIndexValue(type, label, accessor(value));
}
return (value: unknown) => {
if (value === null || typeof value !== "object") {
return undefined;
}
const fieldValue = (value as Record<string, unknown>)[accessor];
return this.#normalizeIndexValue(type, `${label} field "${accessor}"`, fieldValue);
};
}
#normalizeIndexValue(
type: string,
label: string,
value: unknown,
): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
switch (typeof value) {
case "string":
return value;
case "number":
case "boolean":
case "bigint":
return String(value);
default:
throw new Error(`CultCache ${label} accessor for type "${type}" produced a non-scalar value.`);
}
}
#requireDefinition<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): RegisteredDefinition {
const registered = this.#definitions.get(definition.type);
if (!registered || registered.definition !== definition) {
throw new Error(`CultCache document type "${definition.type}" is not registered on this cache instance.`);
}
return registered;
}
#resolveDefinitionForEnvelope(entry: CultCacheEnvelope): RegisteredDefinition | undefined {
if (entry.schemaId) {
return this.#schemaIdDefinitions.get(entry.schemaId) ?? this.#definitions.get(entry.type);
}
return this.#definitions.get(entry.type);
}
#resolveRoute(type: string): StoreRoute {
const typeSpecific = this.#stores.filter((registration) => registration.types.includes(type));
if (typeSpecific.length > 0) {
return {
primary: typeSpecific[0]?.store,
mirrors: typeSpecific.slice(1).map((registration) => registration.store),
};
}
const generic = this.#stores.filter((registration) => registration.types.length === 0);
return {
primary: generic[0]?.store,
mirrors: generic.slice(1).map((registration) => registration.store),
};
}
#applyHydratedEntry(
registered: RegisteredDefinition,
entry: CultCacheEnvelope,
value: unknown,
source: "pull" | "put",
): void {
const entryId = this.#entryId(entry.type, entry.key);
const existing = this.#entries.get(entryId);
if (existing) {
this.#removeHydratedEntry(registered, existing);
}
if (registered.global) {
const currentGlobalKey = this.#globalKeys.get(entry.type);
if (currentGlobalKey && currentGlobalKey !== entry.key) {
throw new Error(
source === "pull"
? `CultCache global document type "${entry.type}" has multiple persisted entries.`
: `CultCache global document type "${entry.type}" already has a different key "${currentGlobalKey}".`,
);
}
}
const hydrated: HydratedEntry = {
...entry,
payload: this.#cloneBytes(entry.payload),
schemaId: entry.schemaId ?? registered.catalogEntry.schemaId,
catalogEntry: entry.catalogEntry ?? registered.catalogEntry,
value,
};
this.#entries.set(entryId, hydrated);
this.#getOrCreateTypeEntrySet(entry.type).add(entryId);
if (registered.global) {
this.#globalKeys.set(entry.type, entry.key);
}
if (registered.nameAccessor) {
const name = registered.nameAccessor(value);
if (name !== undefined) {
this.#getOrCreateNameMap(entry.type).set(name, entry.key);
}
}
for (const [indexName, accessor] of registered.indexAccessors) {
const indexValue = accessor(value);
if (indexValue !== undefined) {
this.#getOrCreateIndexMap(entry.type, indexName).set(indexValue, entry.key);
}
}
}
#removeHydratedEntry(
registered: RegisteredDefinition,
entry: HydratedEntry,
): void {
const entryId = this.#entryId(entry.type, entry.key);
this.#entries.delete(entryId);
const typeEntryIds = this.#typeEntryIds.get(entry.type);
if (typeEntryIds) {
typeEntryIds.delete(entryId);
if (typeEntryIds.size === 0) {
this.#typeEntryIds.delete(entry.type);
}
}
if (registered.global && this.#globalKeys.get(entry.type) === entry.key) {
this.#globalKeys.delete(entry.type);
}
if (registered.nameAccessor) {
const name = registered.nameAccessor(entry.value);
if (name !== undefined) {
this.#nameToKeyMaps.get(entry.type)?.delete(name);
}
}
for (const [indexName, accessor] of registered.indexAccessors) {
const value = accessor(entry.value);
if (value !== undefined) {
this.#indexToKeyMaps.get(this.#indexId(entry.type, indexName))?.delete(value);
}
}
}
#rebuildDefinitionLookups(registered: RegisteredDefinition): void {
this.#rebuildNameLookup(registered);
for (const indexName of registered.indexAccessors.keys()) {
this.#rebuildIndexLookup(registered, indexName);
}
}
#rebuildNameLookup(registered: RegisteredDefinition): void {
this.#nameToKeyMaps.delete(registered.definition.type);
if (!registered.nameAccessor) {
return;
}
const map = this.#getOrCreateNameMap(registered.definition.type);
for (const entry of this.#entriesForType(registered.definition.type)) {
const name = registered.nameAccessor(entry.value);
if (name !== undefined) {
map.set(name, entry.key);
}
}
}
#rebuildIndexLookup(registered: RegisteredDefinition, indexName: string): void {
const lookupId = this.#indexId(registered.definition.type, indexName);
this.#indexToKeyMaps.delete(lookupId);
const accessor = registered.indexAccessors.get(indexName);
if (!accessor) {
return;
}
const map = this.#getOrCreateIndexMap(registered.definition.type, indexName);
for (const entry of this.#entriesForType(registered.definition.type)) {
const value = accessor(entry.value);
if (value !== undefined) {
map.set(value, entry.key);
}
}
}
#entriesForType(type: string): HydratedEntry[] {
const entryIds = this.#typeEntryIds.get(type);
if (!entryIds) {
return [];
}
const entries: HydratedEntry[] = [];
for (const entryId of entryIds) {
const entry = this.#entries.get(entryId);
if (entry) {
entries.push(entry);
}
}
return entries;
}
#getOrCreateTypeEntrySet(type: string): Set<string> {
let set = this.#typeEntryIds.get(type);
if (!set) {
set = new Set<string>();
this.#typeEntryIds.set(type, set);
}
return set;
}
#getOrCreateNameMap(type: string): Map<string, string> {
let map = this.#nameToKeyMaps.get(type);
if (!map) {
map = new Map<string, string>();
this.#nameToKeyMaps.set(type, map);
}
return map;
}
#getOrCreateIndexMap(type: string, indexName: string): Map<string, string> {
const lookupId = this.#indexId(type, indexName);
let map = this.#indexToKeyMaps.get(lookupId);
if (!map) {
map = new Map<string, string>();
this.#indexToKeyMaps.set(lookupId, map);
}
return map;
}
#toEnvelope(entry: HydratedEntry): CultCacheEnvelope {
return {
key: entry.key,
type: entry.type,
payload: this.#cloneBytes(entry.payload),
storedAt: entry.storedAt,
schemaId: entry.schemaId,
catalogEntry: entry.catalogEntry,
};
}
#resetHydratedState(): void {
this.#entries.clear();
this.#typeEntryIds.clear();
this.#nameToKeyMaps.clear();
this.#indexToKeyMaps.clear();
this.#globalKeys.clear();
}
#entryId(type: string, key: string): string {
return `${type}::${key}`;
}
#indexId(type: string, indexName: string): string {
return `${type}::${indexName}`;
}
#cloneBytes(payload: Uint8Array): Uint8Array {
return new Uint8Array(payload);
}
}

View file

@ -1,78 +0,0 @@
import type {
AnyCultCacheDocumentDefinition,
CultCacheDocumentDefinition,
CultCacheDocumentRegistry,
CultCacheSchema,
} from "./types";
export function defineDocumentType<TSchema extends CultCacheSchema>(
definition: CultCacheDocumentDefinition<TSchema>,
): CultCacheDocumentDefinition<TSchema> {
if (!definition.type || definition.type.trim().length === 0) {
throw new Error("CultCache document types must declare a non-empty type.");
}
for (const [fieldName, fieldValue] of [
["schemaId", definition.schemaId],
["schemaName", definition.schemaName],
["schemaVersion", definition.schemaVersion],
["contentHash", definition.contentHash],
["canonicalSchemaJson", definition.canonicalSchemaJson],
] as const) {
if (fieldValue !== undefined && fieldValue.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an empty ${fieldName}.`);
}
}
if (definition.name !== undefined && typeof definition.name !== "string" && typeof definition.name !== "function") {
throw new Error(`CultCache document type "${definition.type}" declares an invalid name accessor.`);
}
if (Array.isArray(definition.indexes)) {
const seen = new Set<string>();
for (const index of definition.indexes) {
if (!index.name || index.name.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
}
if (seen.has(index.name)) {
throw new Error(`CultCache document type "${definition.type}" declares duplicate index "${index.name}".`);
}
seen.add(index.name);
}
} else if (definition.indexes) {
for (const name of Object.keys(definition.indexes)) {
if (!name || name.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
}
}
}
if (definition.members) {
const seenSlots = new Set<number>();
for (const member of definition.members) {
if (!Number.isInteger(member.slot) || member.slot < 0) {
throw new Error(`CultCache document type "${definition.type}" declares an invalid schema member slot.`);
}
if (seenSlots.has(member.slot)) {
throw new Error(`CultCache document type "${definition.type}" declares duplicate schema member slot ${member.slot}.`);
}
seenSlots.add(member.slot);
if (!member.memberName || member.memberName.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares a schema member with an empty name.`);
}
if (!member.typeName || member.typeName.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares schema member "${member.memberName}" with an empty type.`);
}
}
}
return Object.freeze({ ...definition });
}
export function defineDocumentRegistry<
TDefinitions extends readonly AnyCultCacheDocumentDefinition[],
>(...definitions: TDefinitions): CultCacheDocumentRegistry<TDefinitions> {
return Object.freeze({
definitions: Object.freeze([...definitions]) as TDefinitions,
});
}

57
src/huginn-eve-dsl.ts Normal file
View file

@ -0,0 +1,57 @@
import type { CultCacheInspection, InspectedCatalogEntry, InspectedRecord } from "cultcache-ts/inspection";
export function buildHuginnEveDsl(inspection: CultCacheInspection): string {
return [
"# Huginn emits Eve DSL. Renderers compile and lower it.",
`surface cultcache.huginn.inspector ${quoteDsl("Huginn .cc Inspector")}`,
"version 1",
"",
"collection huginn.catalog.entries",
...inspection.catalog.map((entry) => `item huginn.catalog.entries ${quoteDsl(renderCatalogEntry(entry))}`),
"",
"collection huginn.records.entries",
...inspection.records.map((record) => `item huginn.records.entries ${quoteDsl(renderRecord(record))}`),
"",
"card source \"Source Witness\"",
`text ${quoteDsl(`file: ${inspection.filePath}`)}`,
`text ${quoteDsl(`format: ${inspection.format}`)}`,
`text ${quoteDsl(`bytes: ${inspection.fileSizeBytes}`)}`,
"text \".cc bytes are authority; Huginn only emits Eve DSL.\"",
"end",
"",
"card catalog \"Schema Catalog\"",
`text ${quoteDsl(`schemas: ${inspection.catalog.length}`)}`,
"list \"Catalog Entries\" bind huginn.catalog.entries",
"end",
"",
"card records \"Persisted Records\"",
`text ${quoteDsl(`records: ${inspection.records.length}`)}`,
"list \"Record Entries\" bind huginn.records.entries",
"end",
"",
].join("\n");
}
function renderCatalogEntry(entry: InspectedCatalogEntry): string {
return [
entry.schemaName,
entry.schemaVersion,
entry.schemaId,
`${entry.members.length} members`,
].join(" | ");
}
function renderRecord(record: InspectedRecord): string {
const status = record.payloadDecodeError ? `decode error: ${record.payloadDecodeError}` : "decoded";
return [
record.key,
record.schemaName,
record.storedAt,
`${record.payloadBytes} bytes`,
status,
].join(" | ");
}
function quoteDsl(value: string): string {
return JSON.stringify(value);
}

View file

@ -1,29 +1,8 @@
export { CultCache, CultCacheBuilder } from "./cult-cache";
export { inspectCultCacheBytes } from "./cult-cache-inspector";
export { defineDocumentRegistry, defineDocumentType } from "./document";
export { SingleFileMessagePackBackingStore } from "./single-file-messagepack-backing-store";
export type {
AnyCultCacheDocumentDefinition,
CacheBackingStore,
CultCacheDocumentAccessor,
CultCacheDocumentDefinition,
CultCacheDocumentFieldName,
CultCacheDocumentFormatter,
CultCacheDocumentIndexDefinition,
CultCacheDocumentRegistry,
CultCacheSchemaCatalogEntry,
CultCacheSchemaCatalogMember,
CultCacheSchema,
CultCacheDocumentValue,
CultCacheEnvelope,
CultCacheIndexScalar,
InferCultCacheSchemaValue,
CultCacheStoreRegistration,
PushAllOptions,
} from "./types";
export { buildHuginnEveDsl } from "./huginn-eve-dsl";
export { inspectCultCacheBytes } from "cultcache-ts/inspection";
export type {
CultCacheInspection,
InspectedCatalogEntry,
InspectedCatalogMember,
InspectedRecord,
} from "./cult-cache-inspector";
} from "cultcache-ts/inspection";

View file

@ -1,424 +0,0 @@
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { decode, encode } from "@msgpack/msgpack";
import { z } from "zod";
import type {
CacheBackingStore,
CultCacheEnvelope,
CultCacheSchemaCatalogEntry,
CultCacheSchemaCatalogMember,
PushAllOptions,
} from "./types";
const STORE_FORMAT_VERSION = "cultcache.store.v1";
const envelopeSchema = z.object({
key: z.string().min(1),
type: z.string().min(1),
payload: z.instanceof(Uint8Array),
storedAt: z.string().min(1),
schemaId: z.string().min(1).optional(),
});
const envelopeArraySchema = z.array(envelopeSchema);
const legacyEnvelopeArraySchema = z.array(
z.object({
key: z.string().min(1),
type: z.string().min(1),
payload: z.unknown(),
storedAt: z.string().min(1),
}),
);
export class SingleFileMessagePackBackingStore implements CacheBackingStore {
readonly filePath: string;
#writeQueue: Promise<void> = Promise.resolve();
constructor(filePath: string) {
this.filePath = resolve(filePath);
}
async pullAll(): Promise<CultCacheEnvelope[]> {
try {
const data = await readFile(this.filePath);
if (data.length === 0) {
return [];
}
const decoded = decode(data);
const snapshot = decodeSnapshot(decoded);
if (snapshot) {
return snapshot.records.map((record) => {
const catalogEntry = snapshot.catalogBySchemaId.get(record.schemaId);
if (!catalogEntry) {
throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`);
}
return {
key: record.key,
type: catalogEntry.schemaName,
schemaId: record.schemaId,
catalogEntry,
payload: record.payload,
storedAt: record.storedAt,
};
});
}
const legacy = decodeLegacyEnvelopeArray(decoded);
if (legacy) {
let repairedLegacyPayload = false;
const normalized = legacy.map((entry) => {
const payload = normalizePayload(entry.payload);
if (payload !== entry.payload) {
repairedLegacyPayload = true;
}
return {
...entry,
payload,
};
});
const parsed = envelopeArraySchema.parse(normalized) as CultCacheEnvelope[];
if (repairedLegacyPayload) {
await this.#writeAll(parsed);
}
return parsed;
}
throw new Error(`CultCache file ${this.filePath} is not a recognized CultCache MessagePack store.`);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return [];
}
throw error;
}
}
async push(entry: CultCacheEnvelope): Promise<void> {
await this.#enqueue(async () => {
const existing = await this.pullAll();
const filtered = existing.filter(
(candidate) => !(candidate.type === entry.type && candidate.key === entry.key),
);
filtered.push(entry);
await this.#writeAll(filtered);
});
}
async delete(entry: CultCacheEnvelope): Promise<void> {
await this.#enqueue(async () => {
const existing = await this.pullAll();
const filtered = existing.filter(
(candidate) => !(candidate.type === entry.type && candidate.key === entry.key),
);
await this.#writeAll(filtered);
});
}
async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise<void> {
await this.#enqueue(async () => {
if (options.soft) {
try {
await readFile(this.filePath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
throw error;
}
}
}
await this.#writeAll(entries);
});
}
async #enqueue<T>(operation: () => Promise<T>): Promise<T> {
let result!: T;
const next = this.#writeQueue.then(async () => {
result = await operation();
});
this.#writeQueue = next.then(
() => undefined,
() => undefined,
);
await next;
return result;
}
async #writeAll(entries: CultCacheEnvelope[]): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}`;
try {
await writeFile(tempPath, encode(encodeSnapshot(entries)));
await rename(tempPath, this.filePath);
} catch (error) {
await rm(tempPath, { force: true }).catch(() => undefined);
throw error;
}
}
}
type PersistedRecord = {
key: string;
schemaId: string;
storedAt: string;
payload: Uint8Array;
};
type DecodedSnapshot = {
catalogBySchemaId: Map<string, CultCacheSchemaCatalogEntry>;
records: PersistedRecord[];
};
function encodeSnapshot(entries: CultCacheEnvelope[]): unknown[] {
const catalog = [...catalogEntriesFor(entries).values()]
.sort((left, right) => compareOrdinal(left.schemaName, right.schemaName));
const records = [...entries]
.sort((left, right) => compareOrdinal(left.key, right.key))
.map((entry) => [
entry.key,
schemaIdForEnvelope(entry),
entry.storedAt,
entry.payload,
]);
return [
STORE_FORMAT_VERSION,
catalog.map(encodeCatalogEntry),
records,
];
}
function catalogEntriesFor(entries: CultCacheEnvelope[]): Map<string, CultCacheSchemaCatalogEntry> {
const catalog = new Map<string, CultCacheSchemaCatalogEntry>();
for (const entry of entries) {
const schemaId = schemaIdForEnvelope(entry);
if (!catalog.has(schemaId)) {
catalog.set(schemaId, entry.catalogEntry ?? {
schemaId,
schemaName: entry.type,
schemaVersion: `${entry.type}.v1`,
contentHash: schemaId,
canonicalSchemaJson: JSON.stringify({
schemaName: entry.type,
schemaVersion: `${entry.type}.v1`,
members: [],
}),
compatibleSchemaIds: [schemaId],
members: [],
});
}
}
return catalog;
}
function schemaIdForEnvelope(entry: CultCacheEnvelope): string {
return entry.schemaId ?? entry.type;
}
function encodeCatalogEntry(entry: CultCacheSchemaCatalogEntry): unknown[] {
return [
entry.schemaId,
entry.schemaName,
entry.schemaVersion,
entry.contentHash,
entry.canonicalSchemaJson,
[...(entry.compatibleSchemaIds ?? [entry.schemaId])],
[...(entry.members ?? [])].map(encodeCatalogMember),
];
}
function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] {
return [
member.slot,
member.memberName,
member.typeName,
member.isReference === true,
member.isMany === true,
member.targetSchemaName ?? null,
member.isName === true,
member.indexAlias ?? null,
];
}
function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) {
return undefined;
}
const catalogRaw = decoded[1];
const recordsRaw = decoded[2];
if (!Array.isArray(catalogRaw) || !Array.isArray(recordsRaw)) {
throw new Error("CultCache v1 snapshot must contain a schema catalog and record array.");
}
const catalogBySchemaId = new Map<string, CultCacheSchemaCatalogEntry>();
for (const entry of catalogRaw) {
const catalogEntry = decodeCatalogEntry(entry);
catalogBySchemaId.set(catalogEntry.schemaId, catalogEntry);
for (const compatibleSchemaId of catalogEntry.compatibleSchemaIds ?? []) {
catalogBySchemaId.set(compatibleSchemaId, catalogEntry);
}
}
const records = recordsRaw.map(decodeRecord);
return { catalogBySchemaId, records };
}
function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry {
if (!Array.isArray(value)) {
throw new Error("CultCache schema catalog entries must be MessagePack arrays.");
}
const [
schemaId = "",
schemaName = "",
schemaVersion = "",
contentHash = "",
canonicalSchemaJson = "",
compatibleSchemaIds = [],
members = [],
] = value;
if (!isNonEmptyString(schemaId) || !isNonEmptyString(schemaName)) {
throw new Error("CultCache schema catalog entries must declare schemaId and schemaName.");
}
if (!Array.isArray(compatibleSchemaIds) || !compatibleSchemaIds.every(isNonEmptyString)) {
throw new Error(`CultCache schema catalog entry "${schemaId}" has invalid compatible schema ids.`);
}
if (!Array.isArray(members)) {
throw new Error(`CultCache schema catalog entry "${schemaId}" has invalid members.`);
}
return {
schemaId,
schemaName,
schemaVersion: isNonEmptyString(schemaVersion) ? schemaVersion : `${schemaName}.v1`,
contentHash: isNonEmptyString(contentHash) ? contentHash : schemaId,
canonicalSchemaJson: typeof canonicalSchemaJson === "string" ? canonicalSchemaJson : "",
compatibleSchemaIds,
members: members.map(decodeCatalogMember),
};
}
function decodeCatalogMember(value: unknown): CultCacheSchemaCatalogMember {
if (!Array.isArray(value)) {
throw new Error("CultCache schema catalog members must be MessagePack arrays.");
}
const [
slot = -1,
memberName = "",
typeName = "",
isReference = false,
isMany = false,
targetSchemaName = null,
isName = false,
indexAlias = null,
] = value;
if (!Number.isInteger(slot) || slot < 0 || !isNonEmptyString(memberName) || !isNonEmptyString(typeName)) {
throw new Error("CultCache schema catalog member has invalid slot, name, or type.");
}
return {
slot,
memberName,
typeName,
isReference: isReference === true,
isMany: isMany === true,
targetSchemaName: typeof targetSchemaName === "string" ? targetSchemaName : null,
isName: isName === true,
indexAlias: typeof indexAlias === "string" ? indexAlias : null,
};
}
function decodeRecord(value: unknown): PersistedRecord {
if (!Array.isArray(value)) {
throw new Error("CultCache persisted records must be MessagePack arrays.");
}
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
}
return {
key,
schemaId,
storedAt,
payload: normalizePayload(payload),
};
}
function decodeLegacyEnvelopeArray(decoded: unknown): Array<{
key: string;
type: string;
payload: unknown;
storedAt: string;
schemaId?: string;
}> | undefined {
if (!Array.isArray(decoded)) {
return undefined;
}
return legacyEnvelopeArraySchema.parse(decoded) as Array<{
key: string;
type: string;
payload: unknown;
storedAt: string;
schemaId?: string;
}>;
}
function normalizePayload(payload: unknown): Uint8Array {
if (payload instanceof Uint8Array) {
return payload;
}
if (
isObject(payload) &&
payload.type === "Buffer" &&
Array.isArray(payload.data) &&
payload.data.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)
) {
return Uint8Array.from(payload.data);
}
if (Array.isArray(payload) && payload.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) {
return Uint8Array.from(payload);
}
return encode(payload);
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function compareOrdinal(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}

View file

@ -1,100 +0,0 @@
export type CultCacheIndexScalar = string | number | boolean | bigint;
export interface CultCacheSchema<TValue = unknown> {
parse(input: unknown): TValue;
}
export type InferCultCacheSchemaValue<TSchema extends CultCacheSchema> =
TSchema extends CultCacheSchema<infer TValue> ? TValue : never;
export type CultCacheDocumentFieldName<TValue> = TValue extends object ? Extract<keyof TValue, string> : never;
export type CultCacheDocumentAccessor<TValue> =
| CultCacheDocumentFieldName<TValue>
| ((value: TValue) => CultCacheIndexScalar | null | undefined);
export interface CultCacheDocumentFormatter<TValue> {
encode(value: TValue): Uint8Array;
decode(payload: Uint8Array): TValue;
}
export interface CultCacheSchemaCatalogMember {
slot: number;
memberName: string;
typeName: string;
isReference?: boolean;
isMany?: boolean;
targetSchemaName?: string | null;
isName?: boolean;
indexAlias?: string | null;
}
export interface CultCacheSchemaCatalogEntry {
schemaId: string;
schemaName: string;
schemaVersion: string;
contentHash: string;
canonicalSchemaJson: string;
compatibleSchemaIds?: readonly string[];
members?: readonly CultCacheSchemaCatalogMember[];
}
export interface CultCacheDocumentIndexDefinition<TValue> {
name: string;
accessor: CultCacheDocumentAccessor<TValue>;
}
export interface CultCacheDocumentDefinition<TSchema extends CultCacheSchema = CultCacheSchema> {
type: string;
schema: TSchema;
schemaId?: string;
schemaName?: string;
schemaVersion?: string;
contentHash?: string;
canonicalSchemaJson?: string;
compatibleSchemaIds?: readonly string[];
members?: readonly CultCacheSchemaCatalogMember[];
global?: boolean;
name?: CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>;
indexes?:
| Readonly<Record<string, CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>>>
| readonly CultCacheDocumentIndexDefinition<InferCultCacheSchemaValue<TSchema>>[];
formatter?: CultCacheDocumentFormatter<InferCultCacheSchemaValue<TSchema>>;
}
export type CultCacheDocumentValue<TDefinition extends CultCacheDocumentDefinition> = InferCultCacheSchemaValue<
TDefinition["schema"]
>;
export type AnyCultCacheDocumentDefinition = CultCacheDocumentDefinition<any>;
export interface CultCacheDocumentRegistry<
TDefinitions extends readonly AnyCultCacheDocumentDefinition[] = readonly AnyCultCacheDocumentDefinition[],
> {
readonly definitions: TDefinitions;
}
export interface CultCacheEnvelope {
key: string;
type: string;
payload: Uint8Array;
storedAt: string;
schemaId?: string;
catalogEntry?: CultCacheSchemaCatalogEntry;
}
export interface PushAllOptions {
soft?: boolean;
}
export interface CacheBackingStore {
pullAll(): Promise<CultCacheEnvelope[]>;
push(entry: CultCacheEnvelope): Promise<void>;
delete(entry: CultCacheEnvelope): Promise<void>;
pushAll?(entries: CultCacheEnvelope[], options?: PushAllOptions): Promise<void>;
}
export interface CultCacheStoreRegistration {
store: CacheBackingStore;
types: string[];
}

View file

@ -1,724 +0,0 @@
import assert from "node:assert/strict";
import { exec, execFile } from "node:child_process";
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { test } from "node:test";
import { promisify } from "node:util";
import { decode, encode } from "@msgpack/msgpack";
import { z } from "zod";
import { CultCache } from "../src/cult-cache";
import { inspectCultCacheBytes } from "../src/cult-cache-inspector";
import { defineDocumentRegistry, defineDocumentType } from "../src/document";
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
import type { CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types";
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const cargoCommand = process.env.CARGO ?? (process.platform === "win32" ? join(homedir(), ".cargo", "bin", "cargo.exe") : "cargo");
const dotnetCommand = process.env.DOTNET ?? (process.platform === "win32" ? join("C:", "Program Files", "dotnet", "dotnet.exe") : "dotnet");
const cultCacheTsRoot = resolve(__dirname, "../..");
const cultcacheRsRoot = resolve(cultCacheTsRoot, "..", "cultcache-rs");
const cultLibRoot = resolve(cultCacheTsRoot, "..", "CultLib");
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",
);
test("CultCache supports registry bootstrap, global documents, and lookup by name and index", async () => {
const itemDocument = defineDocumentType({
type: "item",
schema: z.object({
name: z.string(),
category: z.string(),
value: z.number().int(),
}),
name: "name",
indexes: {
category: "category",
},
});
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({
theme: z.string(),
retries: z.number().int().nonnegative(),
}),
global: true,
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
const registry = defineDocumentRegistry(itemDocument, settingsDocument);
const cache = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(itemDocument, "item:potion", {
name: "Potion",
category: "Consumable",
value: 50,
});
await cache.putGlobal(settingsDocument, {
theme: "ash",
retries: 3,
});
assert.deepEqual(cache.getByName(itemDocument, "Potion"), {
name: "Potion",
category: "Consumable",
value: 50,
});
assert.equal(cache.getKeyByIndex(itemDocument, "category", "Consumable"), "item:potion");
assert.deepEqual(cache.getGlobal(settingsDocument), {
theme: "ash",
retries: 3,
});
const snapshot = cache.snapshot();
assert.equal(snapshot.length, 2);
assert.ok(snapshot.every((entry) => entry.payload instanceof Uint8Array && entry.payload.length > 0));
const reloaded = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await reloaded.pullAllBackingStores();
assert.deepEqual(reloaded.getByName(itemDocument, "Potion"), {
name: "Potion",
category: "Consumable",
value: 50,
});
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), {
theme: "ash",
retries: 3,
});
});
test("CultCache can register name and index lookups after entries already exist", async () => {
const noteDocument = defineDocumentType({
type: "note",
schema: z.object({
title: z.string(),
author: z.string(),
body: z.string(),
}),
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
const cache = CultCache.builder()
.withDocumentType(noteDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(noteDocument, "note:hello", {
title: "Hello",
author: "ari",
body: "world",
});
cache.registerNameLookup(noteDocument, "title");
cache.registerIndex(noteDocument, "author", "author");
assert.equal(cache.getIdByName(noteDocument, "Hello"), "note:hello");
assert.equal(cache.getIdByIndex(noteDocument, "author", "ari"), "note:hello");
});
test("CultCache fails closed on unknown or illegal persisted polymorphic state", async () => {
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({
theme: z.string(),
}),
global: true,
});
class InMemoryStore implements CacheBackingStore {
constructor(private readonly entries: CultCacheEnvelope[]) {}
async pullAll(): Promise<CultCacheEnvelope[]> {
return this.entries;
}
async push(): Promise<void> {
throw new Error("not used");
}
async delete(): Promise<void> {
throw new Error("not used");
}
}
const cacheWithUnknownType = CultCache.builder()
.withDocumentType(settingsDocument)
.withGenericStore(new InMemoryStore([
{
key: "mystery",
type: "forbidden",
payload: encode({ theme: "ash" }),
storedAt: new Date().toISOString(),
},
]))
.build();
await assert.rejects(
async () => cacheWithUnknownType.pullAllBackingStores(),
/No schema is registered for persisted document type "forbidden"\./,
);
const cacheWithDuplicateGlobal = CultCache.builder()
.withDocumentType(settingsDocument)
.withGenericStore(new InMemoryStore([
{
key: "one",
type: "settings",
payload: encode({ theme: "ash" }),
storedAt: new Date().toISOString(),
},
{
key: "two",
type: "settings",
payload: encode({ theme: "ember" }),
storedAt: new Date().toISOString(),
},
]))
.build();
await assert.rejects(
async () => cacheWithDuplicateGlobal.pullAllBackingStores(),
/has multiple persisted entries/,
);
});
test("CultCache accepts generated parse-style schemas without a Zod mirror", async () => {
type GeneratedSettings = {
schema_version: "generated.settings.v0";
theme: string;
retries: number;
};
const generatedSchema: CultCacheSchema<GeneratedSettings> = {
parse(input: unknown): GeneratedSettings {
if (!input || typeof input !== "object") {
throw new Error("generated settings must be an object");
}
const value = input as Record<string, unknown>;
if (value.schema_version !== "generated.settings.v0") {
throw new Error("generated settings schema_version mismatch");
}
if (typeof value.theme !== "string") {
throw new Error("generated settings theme must be a string");
}
if (typeof value.retries !== "number") {
throw new Error("generated settings retries must be a number");
}
return {
schema_version: "generated.settings.v0",
theme: value.theme,
retries: value.retries,
};
},
};
const generatedDocument = defineDocumentType({
type: "generated-settings",
schema: generatedSchema,
global: true,
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "generated.msgpack");
const cache = CultCache.builder()
.withDocumentType(generatedDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.putGlobal(generatedDocument, {
schema_version: "generated.settings.v0",
theme: "ash",
retries: 3,
});
const settings = cache.getRequiredGlobal(generatedDocument);
assert.equal(settings.schema_version, "generated.settings.v0");
assert.equal(settings.theme, "ash");
assert.equal(settings.retries, 3);
});
test("CultCache can ingest raw envelopes without re-encoding the payload", async () => {
const noteDocument = defineDocumentType({
type: "note",
schema: z.object({
title: z.string(),
body: z.string(),
}),
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
const origin = CultCache.builder()
.withDocumentType(noteDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
const target = CultCache.builder()
.withDocumentType(noteDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "target.msgpack")))
.build();
await origin.put(noteDocument, "note:hello", {
title: "Hello",
body: "world",
});
const envelope = origin.getRequiredEnvelope(noteDocument, "note:hello");
const applied = await target.putEnvelope(noteDocument, envelope);
assert.deepEqual(applied, {
title: "Hello",
body: "world",
});
assert.deepEqual(target.getRequired(noteDocument, "note:hello"), applied);
assert.deepEqual(target.getRequiredEnvelope(noteDocument, "note:hello").payload, envelope.payload);
});
test("SingleFileMessagePackBackingStore writes the CultCache v1 snapshot shape", async () => {
const namedDocument = defineDocumentType({
type: "tests.named_entry",
schema: z.object({
Name: z.string(),
Value: z.string(),
}),
schemaId: "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8",
schemaName: "tests.named_entry",
schemaVersion: "tests.named_entry.v1",
contentHash: "sha256:23150930afcc1d84f0cb3012ccc2debcb9b4685f62083033bbaab0083f1e832e",
canonicalSchemaJson: "{\"schemaName\":\"tests.named_entry\",\"schemaVersion\":\"tests.named_entry.v1\",\"members\":[{\"slot\":0,\"name\":\"Name\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":1,\"name\":\"Value\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}",
members: [
{
slot: 0,
memberName: "Name",
typeName: "System.String",
isName: true,
},
{
slot: 1,
memberName: "Value",
typeName: "System.String",
},
],
name: "Name",
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "snapshot.msgpack");
const cache = CultCache.builder()
.withDocumentType(namedDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(namedDocument, "record-1", {
Name: "Teeth",
Value: "slot-array",
});
const snapshot = decode(await readFile(storePath)) as unknown[];
assert.equal(snapshot[0], "cultcache.store.v1");
const catalog = snapshot[1] as unknown[][];
assert.equal(catalog.length, 1);
assert.equal(catalog[0]?.[0], "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8");
assert.equal(catalog[0]?.[1], "tests.named_entry");
assert.equal(catalog[0]?.[2], "tests.named_entry.v1");
const records = snapshot[2] as unknown[][];
assert.equal(records.length, 1);
assert.equal(records[0]?.[0], "record-1");
assert.equal(records[0]?.[1], "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8");
assert.ok(records[0]?.[3] instanceof Uint8Array);
});
test("SingleFileMessagePackBackingStore reads CultCache v1 snapshots by schema id", async () => {
const noteDocument = defineDocumentType({
type: "tests.named_entry",
schema: z.object({
Name: z.string(),
Value: z.string(),
}),
schemaId: "schema-1",
schemaName: "tests.named_entry",
schemaVersion: "tests.named_entry.v1",
contentHash: "hash-1",
canonicalSchemaJson: "{\"fields\":2}",
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "csharp.msgpack");
await writeFile(
storePath,
encode([
"cultcache.store.v1",
[
[
"schema-1",
"tests.named_entry",
"tests.named_entry.v1",
"hash-1",
"{\"fields\":2}",
["schema-1"],
[],
],
],
[
[
"record-1",
"schema-1",
"2026-05-08T12:00:00Z",
encode({
Name: "Teeth",
Value: "slot-array",
}),
],
],
]),
);
const cache = CultCache.builder()
.withDocumentType(noteDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.pullAllBackingStores();
assert.deepEqual(cache.getRequired(noteDocument, "record-1"), {
Name: "Teeth",
Value: "slot-array",
});
assert.equal(cache.getRequiredEnvelope(noteDocument, "record-1").schemaId, "schema-1");
});
test("SingleFileMessagePackBackingStore 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 v1 MessagePack stores are readable across TS, Rust, and C#", 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),
},
];
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),
},
];
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"));
}
}
});
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,
]);
});
interface InteropNote {
schemaVersion: "cultcache.interop_note.v1";
documentId: string;
authorRuntimeId: string;
title: string;
body: string;
tags: string[];
}
const interopNoteDocument = defineDocumentType({
type: "cultcache.interop-note",
schemaId: "cultcache.interop-note",
schemaName: "cultcache.interop-note",
schemaVersion: "cultcache.interop_note.v1",
contentHash: "cultcache.interop-note",
canonicalSchemaJson: "{\"schemaName\":\"cultcache.interop-note\",\"schemaVersion\":\"cultcache.interop_note.v1\",\"members\":[{\"slot\":0,\"name\":\"SchemaVersion\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":1,\"name\":\"DocumentId\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":2,\"name\":\"AuthorRuntimeId\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":3,\"name\":\"Title\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":4,\"name\":\"Body\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":5,\"name\":\"Tags\",\"type\":\"System.String[]\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}",
compatibleSchemaIds: ["cultcache.interop-note"],
members: [
{ slot: 0, memberName: "SchemaVersion", typeName: "System.String" },
{ slot: 1, memberName: "DocumentId", typeName: "System.String", isName: true },
{ slot: 2, memberName: "AuthorRuntimeId", typeName: "System.String" },
{ slot: 3, memberName: "Title", typeName: "System.String" },
{ slot: 4, memberName: "Body", typeName: "System.String" },
{ slot: 5, memberName: "Tags", typeName: "System.String[]" },
],
schema: z.object({
schemaVersion: z.literal("cultcache.interop_note.v1"),
documentId: z.string().min(1),
authorRuntimeId: z.string().min(1),
title: z.string().min(1),
body: z.string().min(1),
tags: z.array(z.string().min(1)),
}),
formatter: {
encode(value: InteropNote): Uint8Array {
return encode([
value.schemaVersion,
value.documentId,
value.authorRuntimeId,
value.title,
value.body,
value.tags,
]);
},
decode(payload: Uint8Array): InteropNote {
const decoded = decode(payload);
if (!Array.isArray(decoded) || decoded.length < 5) {
throw new Error("CultCache interop note payload must be a MessagePack slot array.");
}
const [schemaVersion, documentId, authorRuntimeId, title, body, tags] = decoded;
return interopNoteDocument.schema.parse({
schemaVersion,
documentId,
authorRuntimeId,
title,
body,
tags: Array.isArray(tags) ? tags : [],
});
},
},
});
async function writeTsInteropStore(
file: string,
runtimeId: string,
options: { legacyPayload?: boolean; mismatchedPayload?: boolean } = {},
): Promise<InteropNote> {
const cache = CultCache.builder()
.withDocumentType(interopNoteDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(file))
.build();
const note: InteropNote = {
schemaVersion: "cultcache.interop_note.v1",
documentId: `note:${runtimeId}`,
authorRuntimeId: runtimeId,
title: `${runtimeId} wrote a CultCache note`,
body: "The v1 store format is the contract.",
tags: [runtimeId, "ts", "interop"],
};
if (options.legacyPayload || options.mismatchedPayload) {
const payload = options.mismatchedPayload
? encode([note.schemaVersion, note.documentId, 42, note.title, note.body, note.tags])
: encode([note.schemaVersion, note.documentId, note.authorRuntimeId, note.title, note.body]);
await new SingleFileMessagePackBackingStore(file).push({
key: note.documentId,
type: interopNoteDocument.type,
schemaId: interopNoteDocument.schemaId,
catalogEntry: {
schemaId: interopNoteDocument.schemaId!,
schemaName: interopNoteDocument.schemaName!,
schemaVersion: interopNoteDocument.schemaVersion!,
contentHash: interopNoteDocument.contentHash!,
canonicalSchemaJson: interopNoteDocument.canonicalSchemaJson!,
compatibleSchemaIds: [interopNoteDocument.schemaId!],
members: interopNoteDocument.members,
},
storedAt: new Date().toISOString(),
payload,
});
return note;
}
await cache.put(interopNoteDocument, note.documentId, note);
return note;
}
async function readTsInteropStore(file: string): Promise<InteropNote> {
const cache = CultCache.builder()
.withDocumentType(interopNoteDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(file))
.build();
await cache.pullAllBackingStores();
const notes = cache.getAll(interopNoteDocument);
const note = notes[0];
if (!note) {
throw new Error("No cultcache.interop-note records found.");
}
return note;
}
async function buildInteropPeers(): Promise<void> {
if (!(await exists(rustInteropBinary))) {
await execAsync(`"${cargoCommand}" build --quiet --example cultcache_interop`, {
cwd: cultcacheRsRoot,
});
}
if (!(await exists(csharpInteropDll))) {
await execAsync(`"${dotnetCommand}" build "${csharpInteropProject}" -nologo`, {
cwd: cultLibRoot,
});
}
}
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function runJsonCommand(
name: string,
command: string,
args: string[],
cwd: string,
): Promise<any> {
const { stdout, stderr } = await execFileAsync(command, args, { cwd, 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);
}

View file

@ -1,17 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"rootDir": ".",
"types": ["vite/client"]
},
"include": [
"inspector/src/**/*.ts",
"inspector/src/**/*.tsx",
"src/cult-cache-inspector.ts"
]
}

View file

@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"module": "Node16",
"moduleResolution": "Node16",
"lib": [
"ES2022"
],
@ -16,7 +16,9 @@
"outDir": "dist"
},
"include": [
"src/**/*.ts"
"src/index.ts",
"src/huginn-eve-dsl.ts",
"src/cli.ts"
],
"exclude": [
"test/**/*.ts"

View file

@ -1,14 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist-test",
"declaration": false,
"declarationMap": false
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
],
"exclude": []
}

View file

@ -1,24 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
root: "inspector",
base: "./",
plugins: [react()],
resolve: {
alias: {
react: path.resolve(root, "node_modules/react"),
"react-dom": path.resolve(root, "node_modules/react-dom"),
"react/jsx-runtime": path.resolve(root, "node_modules/react/jsx-runtime.js"),
},
dedupe: ["react", "react-dom"],
},
build: {
outDir: "../dist-inspector",
emptyOutDir: true
}
});