Merge Huginn Eve projection branch
This commit is contained in:
commit
91b7fcf8bd
56 changed files with 352 additions and 12209 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
81
AGENTS.md
Normal 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
316
README.md
|
|
@ -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
3
dist/cli.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env node
|
||||
export {};
|
||||
//# sourceMappingURL=cli.d.ts.map
|
||||
1
dist/cli.d.ts.map
vendored
Normal file
1
dist/cli.d.ts.map
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
||||
21
dist/cli.js
vendored
Normal file
21
dist/cli.js
vendored
Normal 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;
|
||||
});
|
||||
37
dist/cult-cache-inspector.d.ts
vendored
37
dist/cult-cache-inspector.d.ts
vendored
|
|
@ -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
|
||||
1
dist/cult-cache-inspector.d.ts.map
vendored
1
dist/cult-cache-inspector.d.ts.map
vendored
|
|
@ -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"}
|
||||
206
dist/cult-cache-inspector.js
vendored
206
dist/cult-cache-inspector.js
vendored
|
|
@ -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
46
dist/cult-cache.d.ts
vendored
|
|
@ -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
|
||||
1
dist/cult-cache.d.ts.map
vendored
1
dist/cult-cache.d.ts.map
vendored
|
|
@ -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
605
dist/cult-cache.js
vendored
|
|
@ -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
4
dist/document.d.ts
vendored
|
|
@ -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
|
||||
1
dist/document.d.ts.map
vendored
1
dist/document.d.ts.map
vendored
|
|
@ -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
66
dist/document.js
vendored
|
|
@ -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
3
dist/huginn-eve-dsl.d.ts
vendored
Normal 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
1
dist/huginn-eve-dsl.d.ts.map
vendored
Normal 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
55
dist/huginn-eve-dsl.js
vendored
Normal 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
9
dist/index.d.ts
vendored
|
|
@ -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
2
dist/index.d.ts.map
vendored
|
|
@ -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
16
dist/index.js
vendored
|
|
@ -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; } });
|
||||
|
|
|
|||
11
dist/single-file-messagepack-backing-store.d.ts
vendored
11
dist/single-file-messagepack-backing-store.d.ts
vendored
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
311
dist/single-file-messagepack-backing-store.js
vendored
311
dist/single-file-messagepack-backing-store.js
vendored
|
|
@ -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
76
dist/types.d.ts
vendored
|
|
@ -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
1
dist/types.d.ts.map
vendored
|
|
@ -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
2
dist/types.js
vendored
|
|
@ -1,2 +0,0 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
4884
package-lock.json
generated
File diff suppressed because it is too large
Load diff
30
package.json
30
package.json
|
|
@ -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
23
src/cli.ts
Normal 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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
57
src/huginn-eve-dsl.ts
Normal 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);
|
||||
}
|
||||
27
src/index.ts
27
src/index.ts
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
100
src/types.ts
100
src/types.ts
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist-test",
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue