Add CultCache inspector app
This commit is contained in:
parent
d7aa3349f3
commit
b460612680
20 changed files with 5965 additions and 10 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,2 +1,5 @@
|
|||
node_modules/
|
||||
dist-test/
|
||||
dist-inspector/
|
||||
release/
|
||||
release-inspector/
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -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
37
dist/cult-cache-inspector.d.ts
vendored
Normal 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
1
dist/cult-cache-inspector.d.ts.map
vendored
Normal 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
206
dist/cult-cache-inspector.js
vendored
Normal 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
2
dist/index.d.ts
vendored
|
|
@ -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
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,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
4
dist/index.js
vendored
|
|
@ -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
27
electron-builder.json
Normal 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
43
electron/main.cjs
Normal 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
12
inspector/index.html
Normal 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
235
inspector/src/main.ts
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
265
inspector/src/styles.css
Normal file
265
inspector/src/styles.css
Normal 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
4731
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -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
305
src/cult-cache-inspector.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
12
tsconfig.inspector.json
Normal 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
9
vite.inspector.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
root: "inspector",
|
||||
build: {
|
||||
outDir: "../dist-inspector",
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue