Add CultCache inspector app

This commit is contained in:
Meta Vens 2026-05-16 11:06:33 +01:00
commit b460612680
20 changed files with 5965 additions and 10 deletions

3
.gitignore vendored
View file

@ -1,2 +1,5 @@
node_modules/
dist-test/
dist-inspector/
release/
release-inspector/

View file

@ -32,6 +32,34 @@ This is not an ORM in a fake mustache. It is for cases where you want:
That keeps the polymorphic boundary tight without making callers write boilerplate by candlelight.
## Inspector
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.
Run the local inspector during development:
```sh
npm run dev:inspector
```
Build the Vite inspector bundle:
```sh
npm run build:inspector
```
Build a desktop package:
```sh
npm run dist:inspector
```
The inspector is read-only. Drop a `.cc`, `.msgpack`, or `.mpack` file onto the
window to inspect the snapshot header, schema catalog, records, and decoded
MessagePack payload previews without registering application schemas.
## Example
```ts

37
dist/cult-cache-inspector.d.ts vendored Normal file
View file

@ -0,0 +1,37 @@
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 Normal file
View file

@ -0,0 +1 @@
{"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 Normal file
View file

@ -0,0 +1,206 @@
"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;
}

2
dist/index.d.ts vendored
View file

@ -1,5 +1,7 @@
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";
//# sourceMappingURL=index.d.ts.map

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

@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC3D,OAAO,EAAE,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"}
{"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"}

4
dist/index.js vendored
View file

@ -1,9 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SingleFileMessagePackBackingStore = exports.defineDocumentType = exports.defineDocumentRegistry = exports.CultCacheBuilder = exports.CultCache = void 0;
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; } });

27
electron-builder.json Normal file
View file

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

43
electron/main.cjs Normal file
View file

@ -0,0 +1,43 @@
const { app, BrowserWindow } = require("electron");
const path = require("node:path");
const devServer = process.env.CULTCACHE_INSPECTOR_DEV_SERVER;
function createWindow() {
const window = new BrowserWindow({
width: 1180,
height: 780,
minWidth: 820,
minHeight: 560,
title: "CultCache Inspector",
backgroundColor: "#f4f2ea",
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
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();
}
});

12
inspector/index.html Normal file
View file

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

235
inspector/src/main.ts Normal file
View file

@ -0,0 +1,235 @@
import { inspectCultCacheBytes, type CultCacheInspection, type InspectedCatalogEntry, type InspectedRecord } from "../../src/cult-cache-inspector";
import "./styles.css";
const app = document.querySelector<HTMLDivElement>("#app");
if (!app) {
throw new Error("Missing inspector app root.");
}
const appRoot = app;
type Selection = {
record: number;
catalog: number;
};
let inspection: CultCacheInspection | undefined;
let selection: Selection = { record: 0, catalog: 0 };
let errorMessage = "";
let dragging = false;
appRoot.addEventListener("dragover", (event) => {
event.preventDefault();
dragging = true;
render();
});
appRoot.addEventListener("dragleave", (event) => {
if (event.target === appRoot) {
dragging = false;
render();
}
});
appRoot.addEventListener("drop", (event) => {
event.preventDefault();
dragging = false;
const file = event.dataTransfer?.files.item(0);
if (file) {
void inspectFile(file);
}
});
render();
function render(): void {
appRoot.innerHTML = `
<main class="workspace ${dragging ? "is-dragging" : ""}">
<section class="sidebar">
<div class="brand">
<h1>CultCache Inspector</h1>
<p>Drop a <code>.cc</code> file and read the wire format without registering app schemas.</p>
</div>
<label class="dropzone">
<input id="file-input" type="file" accept=".cc,.msgpack,.mpack,application/octet-stream" />
<span class="drop-title">Drop CultCache file</span>
<span class="drop-copy">or choose one from disk</span>
</label>
${errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : ""}
${inspection ? renderSummary(inspection) : renderEmptyState()}
</section>
<section class="content">
${inspection ? renderInspection(inspection, selection) : renderNoFile()}
</section>
</main>
`;
appRoot.querySelector<HTMLInputElement>("#file-input")?.addEventListener("change", (event) => {
const file = (event.currentTarget as HTMLInputElement).files?.item(0);
if (file) {
void inspectFile(file);
}
});
appRoot.querySelectorAll<HTMLButtonElement>("[data-record]").forEach((button) => {
button.addEventListener("click", () => {
selection = { ...selection, record: Number(button.dataset.record) };
render();
});
});
appRoot.querySelectorAll<HTMLButtonElement>("[data-catalog]").forEach((button) => {
button.addEventListener("click", () => {
selection = { ...selection, catalog: Number(button.dataset.catalog) };
render();
});
});
}
async function inspectFile(file: File): Promise<void> {
try {
const bytes = new Uint8Array(await file.arrayBuffer());
inspection = inspectCultCacheBytes(file.name, bytes, file.size);
selection = { record: 0, catalog: 0 };
errorMessage = "";
} catch (error) {
inspection = undefined;
errorMessage = error instanceof Error ? error.message : String(error);
}
render();
}
function renderSummary(model: CultCacheInspection): string {
return `
<div class="summary">
${metric("Format", model.format)}
${metric("Records", model.records.length.toString())}
${metric("Schemas", model.catalog.length.toString())}
${metric("Bytes", model.fileSizeBytes.toString())}
</div>
`;
}
function renderEmptyState(): string {
return `
<div class="summary">
${metric("Format", ".cc")}
${metric("Mode", "Read-only")}
${metric("Payload", "MessagePack")}
${metric("Runtime", "Vite")}
</div>
`;
}
function renderNoFile(): string {
return `
<div class="hero">
<h2>No file loaded</h2>
<p>Drag a CultCache <code>.cc</code> store into the window. The inspector shows the snapshot header, schema catalog, records, and decoded payload slot arrays.</p>
</div>
`;
}
function renderInspection(model: CultCacheInspection, current: Selection): string {
const record = model.records[current.record];
const catalogEntry = model.catalog[current.catalog];
return `
<div class="columns">
<section class="panel">
<header><h2>Records</h2><span>${model.records.length}</span></header>
<div class="list">
${model.records.map((entry, index) => renderRecordButton(entry, index, index === current.record)).join("") || `<div class="empty">No records</div>`}
</div>
</section>
<section class="panel detail">
<header><h2>Record Detail</h2><span>${escapeHtml(model.filePath)}</span></header>
${record ? renderRecordDetail(record) : `<div class="empty">No record selected</div>`}
</section>
</div>
<div class="columns bottom">
<section class="panel">
<header><h2>Schema Catalog</h2><span>${model.catalog.length}</span></header>
<div class="list">
${model.catalog.map((entry, index) => renderCatalogButton(entry, index, index === current.catalog)).join("") || `<div class="empty">No schema catalog</div>`}
</div>
</section>
<section class="panel detail">
<header><h2>Catalog Entry</h2><span>${catalogEntry ? escapeHtml(catalogEntry.schemaVersion) : ""}</span></header>
${catalogEntry ? renderCatalogDetail(catalogEntry) : `<div class="empty">No catalog entry selected</div>`}
</section>
</div>
`;
}
function renderRecordButton(record: InspectedRecord, index: number, selected: boolean): string {
return `
<button class="row" type="button" data-record="${index}" aria-selected="${selected}">
<strong>${escapeHtml(record.key)}</strong>
<span>${escapeHtml(record.schemaName)} · ${record.payloadBytes} bytes</span>
</button>
`;
}
function renderCatalogButton(entry: InspectedCatalogEntry, index: number, selected: boolean): string {
return `
<button class="row" type="button" data-catalog="${index}" aria-selected="${selected}">
<strong>${escapeHtml(entry.schemaName)}</strong>
<span>${escapeHtml(entry.schemaId)}</span>
</button>
`;
}
function renderRecordDetail(record: InspectedRecord): string {
return `
${facts([
["Key", record.key],
["Schema", record.schemaName],
["Schema ID", record.schemaId],
["Stored", record.storedAt],
["Payload", `${record.payloadBytes} bytes`],
])}
${record.payloadDecodeError ? `<div class="error">${escapeHtml(record.payloadDecodeError)}</div>` : ""}
<pre>${escapeHtml(JSON.stringify(record.payloadPreview, null, 2))}</pre>
`;
}
function renderCatalogDetail(entry: InspectedCatalogEntry): string {
return `
${facts([
["Schema", entry.schemaName],
["Version", entry.schemaVersion],
["Schema ID", entry.schemaId],
["Hash", entry.contentHash],
["Compatible", entry.compatibleSchemaIds.join(", ")],
])}
<pre>${escapeHtml(JSON.stringify({
members: entry.members,
canonicalSchemaJson: parseJson(entry.canonicalSchemaJson),
}, null, 2))}</pre>
`;
}
function facts(rows: Array<[string, string]>): string {
return `<dl>${rows.map(([name, value]) => `<dt>${escapeHtml(name)}</dt><dd>${escapeHtml(value)}</dd>`).join("")}</dl>`;
}
function metric(label: string, value: string): string {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
}
function parseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

265
inspector/src/styles.css Normal file
View file

@ -0,0 +1,265 @@
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1b2421;
background: #f4f2ea;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 760px;
min-height: 100vh;
}
button,
input {
font: inherit;
}
code {
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 0.92em;
}
.workspace {
display: grid;
grid-template-columns: 310px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
border-right: 1px solid #cfd7d0;
background: #fffdf7;
padding: 22px;
}
.brand h1 {
margin: 0;
font-size: 25px;
line-height: 1.05;
}
.brand p {
margin: 10px 0 18px;
color: #61716b;
font-size: 13px;
line-height: 1.45;
}
.dropzone {
display: grid;
gap: 6px;
place-items: center;
min-height: 132px;
border: 2px dashed #8ba59e;
border-radius: 8px;
background: #eef7f4;
color: #123f3b;
cursor: pointer;
text-align: center;
}
.is-dragging .dropzone {
border-color: #0f766e;
background: #dff4ef;
}
.dropzone input {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.drop-title {
font-weight: 800;
}
.drop-copy {
color: #61716b;
font-size: 13px;
}
.summary {
display: grid;
gap: 10px;
margin-top: 16px;
}
.metric {
border: 1px solid #d6ddd7;
border-radius: 8px;
background: #ffffff;
padding: 11px;
}
.metric span {
display: block;
color: #61716b;
font-size: 12px;
}
.metric strong {
display: block;
margin-top: 4px;
overflow-wrap: anywhere;
}
.content {
padding: 18px;
overflow: auto;
}
.hero {
display: grid;
align-content: center;
min-height: calc(100vh - 36px);
max-width: 620px;
}
.hero h2 {
margin: 0;
font-size: 42px;
line-height: 1;
}
.hero p {
margin: 14px 0 0;
color: #61716b;
line-height: 1.55;
}
.columns {
display: grid;
grid-template-columns: minmax(260px, 0.82fr) minmax(420px, 1.18fr);
gap: 14px;
}
.bottom {
margin-top: 14px;
}
.panel {
min-width: 0;
border: 1px solid #cfd7d0;
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
.panel header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
border-bottom: 1px solid #dce2dd;
padding: 11px 12px;
}
.panel h2 {
margin: 0;
font-size: 15px;
}
.panel header span {
overflow: hidden;
color: #61716b;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.list {
display: grid;
gap: 8px;
max-height: 430px;
overflow: auto;
padding: 10px;
}
.row {
width: 100%;
border: 1px solid #d6ddd7;
border-radius: 6px;
background: #fbfcfa;
color: inherit;
padding: 10px;
text-align: left;
cursor: pointer;
}
.row[aria-selected="true"] {
border-color: #0f766e;
box-shadow: inset 3px 0 0 #0f766e;
}
.row strong,
.row span {
display: block;
overflow-wrap: anywhere;
}
.row span {
margin-top: 4px;
color: #61716b;
font-size: 12px;
}
.detail {
min-height: 360px;
}
.detail > dl,
.detail > pre,
.detail > .error {
margin: 12px;
}
dl {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 8px 12px;
}
dt {
color: #61716b;
font-size: 12px;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
pre {
max-height: 440px;
overflow: auto;
border-radius: 6px;
background: #15201e;
color: #f1fff9;
padding: 12px;
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
line-height: 1.45;
}
.error {
border: 1px solid #e0ada6;
border-radius: 6px;
background: #fff3f0;
color: #9c332f;
padding: 10px;
overflow-wrap: anywhere;
}
.empty {
padding: 18px;
color: #61716b;
}

4731
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,10 @@
],
"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"
},
@ -28,6 +32,12 @@
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^42.1.0",
"electron-builder": "^26.8.1",
"typescript": "^5.9.0",
"vite": "^8.0.13",
"wait-on": "^9.0.10"
}
}

305
src/cult-cache-inspector.ts Normal file
View file

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

View file

@ -1,4 +1,5 @@
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 {
@ -20,3 +21,9 @@ export type {
CultCacheStoreRegistration,
PushAllOptions,
} from "./types";
export type {
CultCacheInspection,
InspectedCatalogEntry,
InspectedCatalogMember,
InspectedRecord,
} from "./cult-cache-inspector";

View file

@ -10,6 +10,7 @@ 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";
@ -543,6 +544,27 @@ test("CultCache interop reader accepts missing compatible trailing slots and rej
);
});
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;

12
tsconfig.inspector.json Normal file
View file

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true,
"rootDir": "."
},
"include": [
"inspector/src/**/*.ts",
"src/cult-cache-inspector.ts"
]
}

9
vite.inspector.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from "vite";
export default defineConfig({
root: "inspector",
build: {
outDir: "../dist-inspector",
emptyOutDir: true
}
});