Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e23d911a4 | |||
| 301b05c212 | |||
| 22fce0ac66 |
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
*.d.ts.map
|
||||
*.tsbuildinfo
|
||||
.worktrees/
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||
const entrypoint = resolve(import.meta.dir, "index.ts");
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
storePath?: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const finalArgs = storePath
|
||||
? ["bun", entrypoint, "--store", storePath, ...args]
|
||||
: ["bun", entrypoint, ...args];
|
||||
const proc = Bun.spawn(finalArgs, {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return { stdout, stderr, exitCode };
|
||||
}
|
||||
|
||||
describe("ucas command alias", () => {
|
||||
test("T1: ucas bin entry exists in package.json", async () => {
|
||||
@@ -15,7 +35,6 @@ describe("ucas command alias", () => {
|
||||
});
|
||||
|
||||
test("T3: ucas command is executable and shows help", async () => {
|
||||
const entrypoint = resolve(import.meta.dir, "index.ts");
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -31,3 +50,255 @@ describe("ucas command alias", () => {
|
||||
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- @ Alias Resolution Tests ----
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
testDir = join(
|
||||
tmpdir(),
|
||||
`json-cas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
cliPath = join(import.meta.dir, "index.ts");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test directory
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Run CLI command and return stdout, stderr, and exit code
|
||||
*/
|
||||
async function runCliAlias(...args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", "run", cliPath, "--store", storePath, ...args],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: proc.exitCode ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("@ Alias Resolution - schema get", () => {
|
||||
test("ucas schema get @string should work", async () => {
|
||||
await runCliAlias("init"); // Initialize store
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||
"schema",
|
||||
"get",
|
||||
"@string",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "string" });
|
||||
});
|
||||
|
||||
test("ucas schema get @number should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@number");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "number" });
|
||||
});
|
||||
|
||||
test("ucas schema get @object should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@object");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "object" });
|
||||
});
|
||||
|
||||
test("ucas schema get @array should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@array");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "array" });
|
||||
});
|
||||
|
||||
test("ucas schema get @bool should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@bool");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "boolean" });
|
||||
});
|
||||
|
||||
test("ucas schema get @schema should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@schema");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toHaveProperty("type", "object");
|
||||
expect(schema).toHaveProperty(
|
||||
"description",
|
||||
"json-cas JSON Schema meta-schema",
|
||||
);
|
||||
});
|
||||
|
||||
test("ucas schema get @invalid should fail gracefully", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stderr, exitCode } = await runCliAlias("schema", "get", "@invalid");
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Schema not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@ Alias Resolution - put", () => {
|
||||
test("ucas put @string <file> should resolve alias", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify("hello world"));
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||
"put",
|
||||
"@string",
|
||||
payloadFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
// Should output a valid hash (13 chars)
|
||||
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("ucas put @number <file> should resolve alias", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
writeFileSync(payloadFile, "42");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("put", "@number", payloadFile);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("ucas put @object <file> should resolve alias", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ foo: "bar" }));
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("put", "@object", payloadFile);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("ucas put @invalid <file> should fail", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
writeFileSync(payloadFile, "{}");
|
||||
|
||||
const { stderr, exitCode } = await runCliAlias("put", "@invalid", payloadFile);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@ Alias Resolution - hash", () => {
|
||||
test("ucas hash @string <file> should compute hash without storing", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify("test"));
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||
"hash",
|
||||
"@string",
|
||||
payloadFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ucas render command", () => {
|
||||
test("R1: render requires hash argument", async () => {
|
||||
const { exitCode, stderr } = await runCli(["render"]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Usage");
|
||||
});
|
||||
|
||||
test("R2: render with missing hash shows error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
const { exitCode, stdout } = await runCli(
|
||||
["render", "ZZZZZZZZZZZZZ"],
|
||||
tmpStore,
|
||||
);
|
||||
// Missing hash renders as cas: reference
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("cas:ZZZZZZZZZZZZZ");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("R3: render with invalid numeric flag fails", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["render", "AAAAAAAAAAAAA", "--resolution", "invalid"],
|
||||
tmpStore,
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("valid number");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
InvalidVariableNameError,
|
||||
putSchema,
|
||||
refs,
|
||||
render,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
validate,
|
||||
@@ -28,7 +29,16 @@ import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
type Flags = Record<string, string | boolean | string[]>;
|
||||
|
||||
/** Flags that consume the next token as their value. All others are boolean. */
|
||||
const VALUE_FLAGS = new Set(["store", "format", "var-db", "tag", "schema"]);
|
||||
const VALUE_FLAGS = new Set([
|
||||
"store",
|
||||
"format",
|
||||
"var-db",
|
||||
"tag",
|
||||
"schema",
|
||||
"resolution",
|
||||
"decay",
|
||||
"epsilon",
|
||||
]);
|
||||
|
||||
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
const flags: Flags = {};
|
||||
@@ -109,6 +119,24 @@ function openVarStore(): VariableStore {
|
||||
return createVariableStore(resolve(varDbPath), store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a type-hash, handling @ aliases
|
||||
* If the input starts with @, resolve it via bootstrap
|
||||
* Otherwise, return the hash as-is
|
||||
*/
|
||||
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
|
||||
if (typeHashOrAlias.startsWith("@")) {
|
||||
const store = openStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const resolvedHash = builtinSchemas[typeHashOrAlias];
|
||||
if (!resolvedHash) {
|
||||
die(`Schema not found: ${typeHashOrAlias}`);
|
||||
}
|
||||
return resolvedHash;
|
||||
}
|
||||
return typeHashOrAlias;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Variable schema's CAS hash
|
||||
* This is the type hash used in JSON envelopes
|
||||
@@ -196,14 +224,16 @@ async function cmdInit(): Promise<void> {
|
||||
const dir = resolve(storePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const store = createFsStore(dir);
|
||||
const hash = await bootstrap(store);
|
||||
console.log(hash);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
console.log(metaHash);
|
||||
}
|
||||
|
||||
async function cmdBootstrap(): Promise<void> {
|
||||
const store = openStore();
|
||||
const hash = await bootstrap(store);
|
||||
console.log(hash);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
console.log(metaHash);
|
||||
}
|
||||
|
||||
async function cmdSchemaPut(args: string[]): Promise<void> {
|
||||
@@ -216,17 +246,20 @@ async function cmdSchemaPut(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdSchemaGet(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas schema get <type-hash>");
|
||||
const hashOrAlias = args[0];
|
||||
if (!hashOrAlias) die("Usage: json-cas schema get <type-hash>");
|
||||
const hash = await resolveTypeHash(hashOrAlias);
|
||||
const store = openStore();
|
||||
const schema = getSchema(store, hash);
|
||||
if (schema === null) die(`Schema not found: ${hash}`);
|
||||
if (schema === null) die(`Schema not found: ${hashOrAlias}`);
|
||||
out(schema);
|
||||
}
|
||||
|
||||
async function cmdSchemaList(): Promise<void> {
|
||||
const store = openStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) throw new Error("Meta-schema not found");
|
||||
for (const hash of store.listByType(metaHash)) {
|
||||
if (hash === metaHash) continue;
|
||||
const node = store.get(hash);
|
||||
@@ -252,9 +285,11 @@ async function cmdSchemaValidate(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdPut(args: string[]): Promise<void> {
|
||||
const typeHash = args[0];
|
||||
const typeHashOrAlias = args[0];
|
||||
const file = args[1];
|
||||
if (!typeHash || !file) die("Usage: json-cas put <type-hash> <file.json>");
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas put <type-hash> <file.json>");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
const payload = readJsonFile(file);
|
||||
const store = openStore();
|
||||
const hash = await store.put(typeHash, payload);
|
||||
@@ -339,14 +374,63 @@ async function cmdWalk(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdHash(args: string[]): Promise<void> {
|
||||
const typeHash = args[0];
|
||||
const typeHashOrAlias = args[0];
|
||||
const file = args[1];
|
||||
if (!typeHash || !file) die("Usage: json-cas hash <type-hash> <file.json>");
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas hash <type-hash> <file.json>");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
const payload = readJsonFile(file);
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
console.log(hash);
|
||||
}
|
||||
|
||||
async function cmdRender(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) {
|
||||
die(
|
||||
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]",
|
||||
);
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
|
||||
// Parse numeric options
|
||||
const resolution =
|
||||
typeof flags.resolution === "string"
|
||||
? Number.parseFloat(flags.resolution)
|
||||
: undefined;
|
||||
const decay =
|
||||
typeof flags.decay === "string"
|
||||
? Number.parseFloat(flags.decay)
|
||||
: undefined;
|
||||
const epsilon =
|
||||
typeof flags.epsilon === "string"
|
||||
? Number.parseFloat(flags.epsilon)
|
||||
: undefined;
|
||||
|
||||
// Validate numeric values
|
||||
if (resolution !== undefined && Number.isNaN(resolution)) {
|
||||
die("--resolution must be a valid number");
|
||||
}
|
||||
if (decay !== undefined && Number.isNaN(decay)) {
|
||||
die("--decay must be a valid number");
|
||||
}
|
||||
if (epsilon !== undefined && Number.isNaN(epsilon)) {
|
||||
die("--epsilon must be a valid number");
|
||||
}
|
||||
|
||||
try {
|
||||
const output = render(store, hash, { resolution, decay, epsilon });
|
||||
// Output to stdout without JSON wrapping (raw YAML)
|
||||
process.stdout.write(output);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
die(error.message);
|
||||
}
|
||||
die(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdCat(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas cat <hash>");
|
||||
@@ -575,6 +659,7 @@ Commands:
|
||||
refs <hash> List direct cas_ref edges
|
||||
walk <hash> [--format tree] Recursive traversal
|
||||
hash <type-hash> <file.json> Compute hash without storing (dry run)
|
||||
render <hash> [options] Render node as YAML with resolution decay
|
||||
cat <hash> [--payload] Output node (--payload for payload only)
|
||||
var set <name> <hash> [--tag <tag>...] Create/update a variable
|
||||
var get <name> --schema <hash> Get a variable by name + schema
|
||||
@@ -584,11 +669,14 @@ Commands:
|
||||
gc Run garbage collection
|
||||
|
||||
Flags:
|
||||
--store <path> Store directory (default: ~/.uncaged/json-cas)
|
||||
--var-db <path> Variable database path (default: <store>/variables.db)
|
||||
--json Compact JSON output
|
||||
--schema <hash> Schema hash filter for var get/delete/tag/list
|
||||
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)`);
|
||||
--store <path> Store directory (default: ~/.uncaged/json-cas)
|
||||
--var-db <path> Variable database path (default: <store>/variables.db)
|
||||
--json Compact JSON output
|
||||
--schema <hash> Schema hash filter for var get/delete/tag/list
|
||||
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
|
||||
--resolution <n> Initial resolution for render (default: 1.0)
|
||||
--decay <n> Decay factor for render (default: 0.5)
|
||||
--epsilon <n> Cutoff threshold for render (default: 0.01)`);
|
||||
}
|
||||
|
||||
// ---- Dispatch ----
|
||||
@@ -658,6 +746,10 @@ switch (cmd) {
|
||||
await cmdHash(rest);
|
||||
break;
|
||||
|
||||
case "render":
|
||||
await cmdRender(rest);
|
||||
break;
|
||||
|
||||
case "cat":
|
||||
await cmdCat(rest);
|
||||
break;
|
||||
|
||||
@@ -90,7 +90,8 @@ async function createTestNode(
|
||||
* Get bootstrap type hash
|
||||
*/
|
||||
async function getBootstrapHash(store: Store): Promise<Hash> {
|
||||
return await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
return builtinSchemas["@schema"] ?? "";
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
@@ -43,7 +43,8 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
|
||||
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const hash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
@@ -57,8 +58,8 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
const h1 = await bootstrap(store);
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toBe(h2);
|
||||
expect(store.listByType(h1)).toHaveLength(1);
|
||||
expect(h1).toEqual(h2);
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,7 +105,8 @@ describe("createFsStore – persistence round-trip", () => {
|
||||
|
||||
test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const hash = await bootstrap(store1);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const node = store2.get(hash) as CasNode;
|
||||
@@ -251,10 +253,11 @@ describe("createFsStore – listByType", () => {
|
||||
|
||||
test("bootstrap node is listed under its self type after reload", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const hash = await bootstrap(store1);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listByType(hash)).toEqual([hash]);
|
||||
expect(store2.listByType(hash)).toContain(hash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,7 +287,8 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
|
||||
|
||||
test("verify passes on a disk-loaded bootstrap node", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const hash = await bootstrap(store1);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const node = store2.get(hash) as CasNode;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { getSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Built-in Schema Registration Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bootstrap - Built-in Schemas", () => {
|
||||
test("should return map of built-in schema aliases to hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
// Should return object with 6 aliases
|
||||
expect(builtinSchemas).toHaveProperty("@schema");
|
||||
expect(builtinSchemas).toHaveProperty("@string");
|
||||
expect(builtinSchemas).toHaveProperty("@number");
|
||||
expect(builtinSchemas).toHaveProperty("@object");
|
||||
expect(builtinSchemas).toHaveProperty("@array");
|
||||
expect(builtinSchemas).toHaveProperty("@bool");
|
||||
|
||||
// All values should be valid hashes
|
||||
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("should register @schema as meta-schema alias", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) throw new Error("@schema not found");
|
||||
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
expect(metaSchema).not.toBeNull();
|
||||
expect(metaSchema?.type).toBe("object");
|
||||
expect(metaSchema?.description).toBe("json-cas JSON Schema meta-schema");
|
||||
});
|
||||
|
||||
test("should register @string schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const stringHash = builtinSchemas["@string"];
|
||||
if (!stringHash) throw new Error("@string not found");
|
||||
|
||||
const stringSchema = getSchema(store, stringHash);
|
||||
expect(stringSchema).toEqual({ type: "string" });
|
||||
});
|
||||
|
||||
test("should register @number schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const numberHash = builtinSchemas["@number"];
|
||||
if (!numberHash) throw new Error("@number not found");
|
||||
|
||||
const numberSchema = getSchema(store, numberHash);
|
||||
expect(numberSchema).toEqual({ type: "number" });
|
||||
});
|
||||
|
||||
test("should register @object schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const objectHash = builtinSchemas["@object"];
|
||||
if (!objectHash) throw new Error("@object not found");
|
||||
|
||||
const objectSchema = getSchema(store, objectHash);
|
||||
expect(objectSchema).toEqual({ type: "object" });
|
||||
});
|
||||
|
||||
test("should register @array schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const arrayHash = builtinSchemas["@array"];
|
||||
if (!arrayHash) throw new Error("@array not found");
|
||||
|
||||
const arraySchema = getSchema(store, arrayHash);
|
||||
expect(arraySchema).toEqual({ type: "array" });
|
||||
});
|
||||
|
||||
test("should register @bool schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const boolHash = builtinSchemas["@bool"];
|
||||
if (!boolHash) throw new Error("@bool not found");
|
||||
|
||||
const boolSchema = getSchema(store, boolHash);
|
||||
expect(boolSchema).toEqual({ type: "boolean" });
|
||||
});
|
||||
|
||||
test("should return same hashes on repeated bootstrap calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const first = await bootstrap(store);
|
||||
const second = await bootstrap(store);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
|
||||
// Verify each alias points to same hash
|
||||
expect(first["@string"]).toBe(second["@string"]);
|
||||
expect(first["@number"]).toBe(second["@number"]);
|
||||
expect(first["@object"]).toBe(second["@object"]);
|
||||
expect(first["@array"]).toBe(second["@array"]);
|
||||
expect(first["@bool"]).toBe(second["@bool"]);
|
||||
expect(first["@schema"]).toBe(second["@schema"]);
|
||||
});
|
||||
|
||||
test("all built-in schemas should be typed by meta-schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) throw new Error("@schema not found");
|
||||
|
||||
for (const [alias, hash] of Object.entries(builtinSchemas)) {
|
||||
if (alias === "@schema") continue; // meta-schema is self-typed
|
||||
|
||||
const node = store.get(hash);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.type).toBe(metaHash);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -64,13 +64,32 @@ const BOOTSTRAP_PAYLOAD = {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Write the meta-schema seed node into the store.
|
||||
* The returned hash equals the node's own type field (self-referencing).
|
||||
* Idempotent: calling bootstrap multiple times returns the same hash.
|
||||
* Write the meta-schema seed node into the store and register built-in schemas.
|
||||
* The returned object contains aliases for the meta-schema and 5 primitive schemas.
|
||||
* Idempotent: calling bootstrap multiple times returns the same hashes.
|
||||
*/
|
||||
export async function bootstrap(store: Store): Promise<Hash> {
|
||||
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
||||
if (!isBootstrapCapableStore(store)) {
|
||||
throw new Error("Store does not support bootstrap");
|
||||
}
|
||||
return store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
||||
|
||||
// 1. Bootstrap the meta-schema (self-referential)
|
||||
const metaHash = await store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
||||
|
||||
// 2. Register built-in primitive schemas directly (without putSchema to avoid recursion)
|
||||
const stringHash = await store.put(metaHash, { type: "string" });
|
||||
const numberHash = await store.put(metaHash, { type: "number" });
|
||||
const objectHash = await store.put(metaHash, { type: "object" });
|
||||
const arrayHash = await store.put(metaHash, { type: "array" });
|
||||
const boolHash = await store.put(metaHash, { type: "boolean" });
|
||||
|
||||
// 3. Return map of aliases to hashes
|
||||
return {
|
||||
"@schema": metaHash,
|
||||
"@string": stringHash,
|
||||
"@number": numberHash,
|
||||
"@object": objectHash,
|
||||
"@array": arrayHash,
|
||||
"@bool": boolHash,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,9 +197,17 @@ describe("createMemoryStore – listByType", () => {
|
||||
|
||||
test("bootstrap node is listed under its self type", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
expect(store.listByType(hash)).toEqual([hash]);
|
||||
// All built-in schemas should be typed by the meta-schema
|
||||
const allTypedByMeta = store.listByType(hash);
|
||||
expect(allTypedByMeta).toContain(hash); // meta-schema itself
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@string"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@number"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@object"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@array"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@bool"] ?? "");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,44 +264,59 @@ describe("bootstrap", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a valid 13-char hash", async () => {
|
||||
test("returns a map with 6 built-in schema aliases", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await bootstrap(store);
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
expect(builtinSchemas).toHaveProperty("@schema");
|
||||
expect(builtinSchemas).toHaveProperty("@string");
|
||||
expect(builtinSchemas).toHaveProperty("@number");
|
||||
expect(builtinSchemas).toHaveProperty("@object");
|
||||
expect(builtinSchemas).toHaveProperty("@array");
|
||||
expect(builtinSchemas).toHaveProperty("@bool");
|
||||
|
||||
// All values should be valid hashes
|
||||
for (const hash of Object.values(builtinSchemas)) {
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("node is stored and retrievable", async () => {
|
||||
test("meta-schema node is stored and retrievable", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
expect(store.has(hash)).toBe(true);
|
||||
const node = store.get(hash);
|
||||
expect(store.has(metaHash)).toBe(true);
|
||||
const node = store.get(metaHash);
|
||||
expect(node).not.toBeNull();
|
||||
});
|
||||
|
||||
test("node is self-referencing: type === hash", async () => {
|
||||
test("meta-schema node is self-referencing: type === hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await bootstrap(store);
|
||||
const node = store.get(hash) as CasNode;
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const node = store.get(metaHash) as CasNode;
|
||||
|
||||
expect(node.type).toBe(hash);
|
||||
expect(node.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
test("bootstrap node passes verify()", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await bootstrap(store);
|
||||
const node = store.get(hash) as CasNode;
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const node = store.get(metaHash) as CasNode;
|
||||
|
||||
expect(await verify(hash, node)).toBe(true);
|
||||
expect(await verify(metaHash, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("bootstrap is idempotent: same hash on repeated calls", async () => {
|
||||
test("bootstrap is idempotent: same hashes on repeated calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const h1 = await bootstrap(store);
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toBe(h2);
|
||||
expect(store.listByType(h1)).toHaveLength(1);
|
||||
expect(h1).toEqual(h2);
|
||||
// All 6 built-in schemas should be typed by the meta-schema
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
export { cborEncode } from "./cbor.js";
|
||||
export { type GcStats, gc } from "./gc.js";
|
||||
export { computeHash, computeSelfHash } from "./hash.js";
|
||||
export { type RenderOptions, render } from "./render.js";
|
||||
export type { JSONSchema } from "./schema.js";
|
||||
export {
|
||||
getSchema,
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { render } from "./render.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Hash } from "./types.js";
|
||||
|
||||
describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
test("1.1 Render Simple Primitives", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "hello");
|
||||
|
||||
const output = render(store, hash, { resolution: 1.0 });
|
||||
|
||||
expect(output).toContain("hello");
|
||||
expect(output.trim()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("1.2 Render Object Node (Flat)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const objSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
count: { type: "number" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(objSchema, { name: "test", count: 42 });
|
||||
|
||||
const output = render(store, hash, { resolution: 1.0 });
|
||||
|
||||
expect(output).toContain("name");
|
||||
expect(output).toContain("test");
|
||||
expect(output).toContain("count");
|
||||
expect(output).toContain("42");
|
||||
});
|
||||
|
||||
test("1.3 Render Array Node (Flat)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const arraySchema = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
});
|
||||
const hash = await store.put(arraySchema, [1, 2, 3]);
|
||||
|
||||
const output = render(store, hash, { resolution: 1.0 });
|
||||
|
||||
expect(output).toContain("1");
|
||||
expect(output).toContain("2");
|
||||
expect(output).toContain("3");
|
||||
});
|
||||
|
||||
test("1.4 Render with resolution=0 (Force Reference)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "hello");
|
||||
|
||||
const output = render(store, hash, { resolution: 0 });
|
||||
|
||||
expect(output.trim()).toBe(`cas:${hash}`);
|
||||
});
|
||||
|
||||
test("1.5 Render Non-existent Hash", () => {
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
|
||||
// Non-existent node renders as cas: reference
|
||||
const output = render(store, fakeHash);
|
||||
expect(output.trim()).toBe(`cas:${fakeHash}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 2: Resolution Decay Model", () => {
|
||||
test("2.1 Single-level Nesting with Default Decay", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { content: "leaf" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
child: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
title: "root",
|
||||
child: childHash,
|
||||
});
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("title");
|
||||
expect(output).toContain("root");
|
||||
expect(output).toContain("content");
|
||||
expect(output).toContain("leaf");
|
||||
});
|
||||
|
||||
test("2.2 Multi-level Nesting Reaches Epsilon", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const leafSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "number" },
|
||||
next: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create 8-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
currentHash = await store.put(leafSchema, {
|
||||
value: i,
|
||||
next: currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
const output = render(store, currentHash as Hash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
// At depth 7: resolution = 0.5^7 = 0.0078125 <= 0.01
|
||||
expect(output).toContain("value");
|
||||
expect(output).toContain("0"); // root level
|
||||
// Should contain cas: reference at deep level
|
||||
expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
|
||||
});
|
||||
|
||||
test("2.3 High Decay (Quick Cutoff)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
child: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create 3-level nested structure
|
||||
const level2Hash = await store.put(nodeSchema, { level: 2, child: null });
|
||||
const level1Hash = await store.put(nodeSchema, {
|
||||
level: 1,
|
||||
child: level2Hash,
|
||||
});
|
||||
const rootHash = await store.put(nodeSchema, {
|
||||
level: 0,
|
||||
child: level1Hash,
|
||||
});
|
||||
|
||||
const output = render(store, rootHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.1,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("level");
|
||||
expect(output).toContain("0"); // root
|
||||
expect(output).toContain("1"); // level 1 (0.1 > 0.01)
|
||||
// Level 2 should be reference (0.01 <= 0.01)
|
||||
expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
|
||||
});
|
||||
|
||||
test("2.4 Low Decay (Deep Expansion)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
next: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create 10-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 9; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
const output = render(store, currentHash as Hash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.9,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
// All 10 levels should be expanded (0.9^10 ≈ 0.349 > 0.01)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(output).toContain(`${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("2.5 Starting Resolution Below 1.0", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
next: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create 5-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
const output = render(store, currentHash as Hash, {
|
||||
resolution: 0.5,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
// resolution sequence: 0.5, 0.25, 0.125, 0.0625, 0.03125 (all > 0.01)
|
||||
expect(output).toContain("0");
|
||||
expect(output).toContain("1");
|
||||
expect(output).toContain("2");
|
||||
expect(output).toContain("3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 3: Complex Graph Structures", () => {
|
||||
test("3.1 Multiple Child References", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const itemSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const item1 = await store.put(itemSchema, { name: "item1" });
|
||||
const item2 = await store.put(itemSchema, { name: "item2" });
|
||||
const item3 = await store.put(itemSchema, { name: "item3" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
items: [item1, item2, item3],
|
||||
});
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("item1");
|
||||
expect(output).toContain("item2");
|
||||
expect(output).toContain("item3");
|
||||
});
|
||||
|
||||
test("3.2 Object with Multiple cas_ref Fields", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const leftHash = await store.put(childSchema, { value: "left" });
|
||||
const rightHash = await store.put(childSchema, { value: "right" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
left: { type: "string", format: "cas_ref" },
|
||||
right: { type: "string", format: "cas_ref" },
|
||||
data: { type: "string" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
left: leftHash,
|
||||
right: rightHash,
|
||||
data: "node",
|
||||
});
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("left");
|
||||
expect(output).toContain("right");
|
||||
expect(output).toContain("node");
|
||||
});
|
||||
|
||||
test("3.3 Cycle Detection", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
ref: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hashA = await store.put(nodeSchema, { name: "A", ref: null });
|
||||
const hashB = await store.put(nodeSchema, { name: "B", ref: hashA });
|
||||
|
||||
// Manually update A to reference B (simulate cycle)
|
||||
// Note: In practice, this requires store manipulation
|
||||
// For this test, we'll create a simpler case
|
||||
|
||||
const output = render(store, hashB, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
// Should not infinite loop
|
||||
expect(output).toContain("B");
|
||||
expect(output).toContain("A");
|
||||
});
|
||||
|
||||
test("3.4 DAG (Shared Descendant)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const leafSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const sharedLeaf = await store.put(leafSchema, { value: "shared" });
|
||||
|
||||
const branchSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
child: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
const branchA = await store.put(branchSchema, {
|
||||
name: "A",
|
||||
child: sharedLeaf,
|
||||
});
|
||||
const branchB = await store.put(branchSchema, {
|
||||
name: "B",
|
||||
child: sharedLeaf,
|
||||
});
|
||||
|
||||
const rootSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
left: { type: "string", format: "cas_ref" },
|
||||
right: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
const rootHash = await store.put(rootSchema, {
|
||||
left: branchA,
|
||||
right: branchB,
|
||||
});
|
||||
|
||||
const output = render(store, rootHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("A");
|
||||
expect(output).toContain("B");
|
||||
expect(output).toContain("shared");
|
||||
});
|
||||
|
||||
test("3.5 Deep Tree", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "number" },
|
||||
left: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
right: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create binary tree (just 5 levels for test speed)
|
||||
async function createTree(depth: number, value: number): Promise<Hash> {
|
||||
if (depth === 0) {
|
||||
return store.put(nodeSchema, { value, left: null, right: null });
|
||||
}
|
||||
const left = await createTree(depth - 1, value * 2);
|
||||
const right = await createTree(depth - 1, value * 2 + 1);
|
||||
return store.put(nodeSchema, { value, left, right });
|
||||
}
|
||||
|
||||
const rootHash = await createTree(5, 1);
|
||||
|
||||
const output = render(store, rootHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
// Should complete without error
|
||||
expect(output).toContain("value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
test("4.1 Resolution Exactly at Epsilon", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "test");
|
||||
|
||||
const output = render(store, hash, {
|
||||
resolution: 0.01,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output.trim()).toBe(`cas:${hash}`);
|
||||
});
|
||||
|
||||
test("4.2 Resolution Just Above Epsilon", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "test");
|
||||
|
||||
const output = render(store, hash, {
|
||||
resolution: 0.0100001,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("test");
|
||||
expect(output).not.toContain("cas:");
|
||||
});
|
||||
|
||||
test("4.3 Very Small Epsilon (Deep Expansion)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
next: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create 15-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 14; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
const output = render(store, currentHash as Hash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.000001,
|
||||
});
|
||||
|
||||
// Many levels should be expanded
|
||||
expect(output).toContain("0");
|
||||
expect(output).toContain("5");
|
||||
expect(output).toContain("10");
|
||||
});
|
||||
|
||||
test("4.4 Zero Epsilon (Never Prune)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
next: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create 20-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 19; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
const output = render(store, currentHash as Hash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0,
|
||||
});
|
||||
|
||||
// All levels should be present
|
||||
expect(output).toContain("0");
|
||||
expect(output).toContain("10");
|
||||
expect(output).toContain("19");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 5: YAML Output Format", () => {
|
||||
test("5.1 Valid YAML Syntax", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const objSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
count: { type: "number" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(objSchema, { name: "test", count: 42 });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
// Basic YAML validation - should have key: value pairs
|
||||
expect(output).toMatch(/\w+:/);
|
||||
});
|
||||
|
||||
test("5.2 Nested Object Indentation", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const nestedSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
outer: {
|
||||
type: "object",
|
||||
properties: {
|
||||
inner: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const hash = await store.put(nestedSchema, {
|
||||
outer: { inner: "value" },
|
||||
});
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
// Should have proper indentation (2 spaces)
|
||||
expect(output).toContain("outer");
|
||||
expect(output).toContain("inner");
|
||||
expect(output).toContain("value");
|
||||
});
|
||||
|
||||
test("5.3 Array Rendering", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const arraySchema = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
});
|
||||
const hash = await store.put(arraySchema, [1, 2, 3]);
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
// YAML array format
|
||||
expect(output).toMatch(/[-[].*[1-3]/);
|
||||
});
|
||||
|
||||
test("5.4 CAS Reference in YAML", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { value: "child" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
child: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, { child: childHash });
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.1,
|
||||
epsilon: 0.5,
|
||||
});
|
||||
|
||||
// Child should be rendered as cas: reference
|
||||
expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
|
||||
});
|
||||
|
||||
test("5.5 Special Characters Escaping", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "line1\nline2: value");
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
// Should handle newlines and colons
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
test("5.6 Null Handling", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const nullableSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const hash = await store.put(nullableSchema, { ref: null });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
expect(output).toContain("null");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 6: Schema Integration", () => {
|
||||
test("6.1 Detect cas_ref Fields via Schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { value: "child" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
link: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, { link: childHash });
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("child");
|
||||
});
|
||||
|
||||
test("6.2 Non-cas_ref String Not Expanded", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const objSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(objSchema, { name: "ABC123XYZ9012" });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
// Should be plain string, not expanded
|
||||
expect(output).toContain("ABC123XYZ9012");
|
||||
expect(output).not.toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
|
||||
});
|
||||
|
||||
test("6.3 Array of cas_ref", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const itemSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
const item1 = await store.put(itemSchema, { name: "item1" });
|
||||
const item2 = await store.put(itemSchema, { name: "item2" });
|
||||
|
||||
const arraySchema = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
});
|
||||
const arrayHash = await store.put(arraySchema, [item1, item2]);
|
||||
|
||||
const output = render(store, arrayHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("item1");
|
||||
expect(output).toContain("item2");
|
||||
});
|
||||
|
||||
test("6.4 anyOf with cas_ref (Nullable Reference)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { value: "child" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, { ref: childHash });
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("child");
|
||||
});
|
||||
|
||||
test("6.5 Schema-less Node (Bootstrap Node)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
|
||||
const output = render(store, metaHash);
|
||||
|
||||
// Should render without recursive expansion
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 7: Error Handling", () => {
|
||||
test("7.1 Missing Referenced Node", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
child: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
const parentHash = await store.put(parentSchema, { child: fakeChildHash });
|
||||
|
||||
const output = render(store, parentHash);
|
||||
|
||||
// Should render missing ref as cas:<hash>
|
||||
expect(output).toContain(`cas:${fakeChildHash}`);
|
||||
});
|
||||
|
||||
test("7.3 Invalid Resolution Parameter", () => {
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "AAAAAAAAAAAAA" as Hash;
|
||||
|
||||
expect(() => render(store, fakeHash, { resolution: -1 })).toThrow();
|
||||
});
|
||||
|
||||
test("7.4 Invalid Decay Parameter", () => {
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "AAAAAAAAAAAAA" as Hash;
|
||||
|
||||
expect(() => render(store, fakeHash, { decay: 1.5 })).toThrow();
|
||||
});
|
||||
|
||||
test("7.5 Invalid Epsilon Parameter", () => {
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "AAAAAAAAAAAAA" as Hash;
|
||||
|
||||
expect(() => render(store, fakeHash, { epsilon: -0.01 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 8: Performance & Edge Cases", () => {
|
||||
test("8.1 Large Payload", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const arraySchema = await putSchema(store, {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
name: { type: "string" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const largeArray = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `item${i}`,
|
||||
}));
|
||||
const hash = await store.put(arraySchema, largeArray);
|
||||
|
||||
const start = Date.now();
|
||||
const output = render(store, hash);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
test("8.2 Wide Fan-out", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const itemSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "number" },
|
||||
},
|
||||
});
|
||||
|
||||
const children: Hash[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const hash = await store.put(itemSchema, { value: i });
|
||||
children.push(hash);
|
||||
}
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, children);
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
test("8.3 Empty Payload", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const emptySchema = await putSchema(store, { type: "object" });
|
||||
const hash = await store.put(emptySchema, {});
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
expect(output.trim()).toMatch(/\{\}/);
|
||||
});
|
||||
|
||||
test("8.4 Unicode in Payload", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(textSchema, { text: "你好世界 🌍" });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
expect(output).toContain("你好世界");
|
||||
expect(output).toContain("🌍");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { refs } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
|
||||
export type RenderOptions = {
|
||||
resolution?: number; // (0, 1], default 1.0
|
||||
decay?: number; // (0, 1], default 0.5
|
||||
epsilon?: number; // >= 0, default 0.01
|
||||
};
|
||||
|
||||
const DEFAULT_RESOLUTION = 1.0;
|
||||
const DEFAULT_DECAY = 0.5;
|
||||
const DEFAULT_EPSILON = 0.01;
|
||||
// Small tolerance for floating point comparison
|
||||
const FLOAT_TOLERANCE = 1e-10;
|
||||
|
||||
/**
|
||||
* Render a CAS node as YAML with resolution-based decay.
|
||||
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
|
||||
*/
|
||||
export function render(
|
||||
store: Store,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): string {
|
||||
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
|
||||
const decay = options?.decay ?? DEFAULT_DECAY;
|
||||
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
|
||||
|
||||
// Validate parameters
|
||||
if (resolution < 0 || resolution > 1) {
|
||||
throw new Error("resolution must be in [0, 1]");
|
||||
}
|
||||
if (decay <= 0 || decay > 1) {
|
||||
throw new Error("decay must be in (0, 1]");
|
||||
}
|
||||
if (epsilon < 0) {
|
||||
throw new Error("epsilon must be >= 0");
|
||||
}
|
||||
|
||||
const visited = new Set<Hash>();
|
||||
|
||||
return renderNode(store, hash, resolution, decay, epsilon, visited);
|
||||
}
|
||||
|
||||
function renderNode(
|
||||
store: Store,
|
||||
hash: Hash,
|
||||
currentResolution: number,
|
||||
decay: number,
|
||||
epsilon: number,
|
||||
visited: Set<Hash>,
|
||||
): string {
|
||||
// Check if resolution is below threshold (with floating point tolerance)
|
||||
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
|
||||
// Fetch the node
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
// Missing node - render as cas: reference
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
|
||||
// Cycle detection
|
||||
if (visited.has(hash)) {
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
visited.add(hash);
|
||||
|
||||
// Get references from this node's schema
|
||||
const nodeRefs = refs(store, node);
|
||||
const refSet = new Set(nodeRefs);
|
||||
|
||||
// Calculate child resolution for next level
|
||||
const childResolution = currentResolution * decay;
|
||||
|
||||
// Render the payload with recursive expansion of cas_ref fields
|
||||
const rendered = renderValue(
|
||||
store,
|
||||
node.payload,
|
||||
refSet,
|
||||
childResolution,
|
||||
decay,
|
||||
epsilon,
|
||||
visited,
|
||||
);
|
||||
|
||||
visited.delete(hash);
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function renderValue(
|
||||
store: Store,
|
||||
value: unknown,
|
||||
refHashes: Set<Hash>,
|
||||
childResolution: number,
|
||||
decay: number,
|
||||
epsilon: number,
|
||||
visited: Set<Hash>,
|
||||
): string {
|
||||
// Handle null
|
||||
if (value === null) {
|
||||
return "null\n";
|
||||
}
|
||||
|
||||
// Handle primitives
|
||||
if (typeof value === "string") {
|
||||
// Check if this string is a cas_ref
|
||||
if (refHashes.has(value as Hash)) {
|
||||
// Recursively render the referenced node
|
||||
return renderNode(
|
||||
store,
|
||||
value as Hash,
|
||||
childResolution,
|
||||
decay,
|
||||
epsilon,
|
||||
visited,
|
||||
);
|
||||
}
|
||||
// Otherwise, render as YAML string
|
||||
return toYamlString(value);
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return `${value}\n`;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return "[]\n";
|
||||
}
|
||||
|
||||
const items = value.map((item) => {
|
||||
const itemYaml = renderValue(
|
||||
store,
|
||||
item,
|
||||
refHashes,
|
||||
childResolution,
|
||||
decay,
|
||||
epsilon,
|
||||
visited,
|
||||
);
|
||||
return indent(itemYaml.trim(), 2);
|
||||
});
|
||||
|
||||
return `- ${items.join("\n- ")}\n`;
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
if (typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return "{}\n";
|
||||
}
|
||||
|
||||
const pairs = keys.map((key) => {
|
||||
const val = obj[key];
|
||||
const valYaml = renderValue(
|
||||
store,
|
||||
val,
|
||||
refHashes,
|
||||
childResolution,
|
||||
decay,
|
||||
epsilon,
|
||||
visited,
|
||||
);
|
||||
|
||||
const trimmedVal = valYaml.trim();
|
||||
|
||||
// If value is multiline, indent it
|
||||
if (trimmedVal.includes("\n")) {
|
||||
return `${key}:\n${indent(trimmedVal, 2)}`;
|
||||
}
|
||||
|
||||
return `${key}: ${trimmedVal}`;
|
||||
});
|
||||
|
||||
return `${pairs.join("\n")}\n`;
|
||||
}
|
||||
|
||||
return "null\n";
|
||||
}
|
||||
|
||||
function toYamlString(str: string): string {
|
||||
// Handle special characters
|
||||
if (
|
||||
str.includes("\n") ||
|
||||
str.includes(":") ||
|
||||
str.includes("#") ||
|
||||
str.includes("[") ||
|
||||
str.includes("]") ||
|
||||
str.includes("{") ||
|
||||
str.includes("}") ||
|
||||
str.includes("'") ||
|
||||
str.includes('"') ||
|
||||
str.startsWith(" ") ||
|
||||
str.endsWith(" ")
|
||||
) {
|
||||
// Use double-quoted string with escaping
|
||||
const escaped = str
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, "\\n");
|
||||
return `"${escaped}"\n`;
|
||||
}
|
||||
|
||||
return `${str}\n`;
|
||||
}
|
||||
|
||||
function indent(text: string, spaces: number): string {
|
||||
const prefix = " ".repeat(spaces);
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => (line ? prefix + line : line))
|
||||
.join("\n");
|
||||
}
|
||||
@@ -29,7 +29,8 @@ describe("putSchema", () => {
|
||||
|
||||
test("schema node type equals the meta-schema hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const node = store.get(schemaHash) as CasNode;
|
||||
|
||||
@@ -355,7 +356,8 @@ describe("walk", () => {
|
||||
describe("bootstrap meta-schema self-reference", () => {
|
||||
test("metaNode.type === metaHash (self-referencing)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaNode = store.get(metaHash) as CasNode;
|
||||
|
||||
expect(metaNode.type).toBe(metaHash);
|
||||
@@ -363,7 +365,8 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("schema nodes have type === metaHash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const schemaNode = store.get(schemaHash) as CasNode;
|
||||
|
||||
@@ -372,7 +375,8 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("data nodes have type === schemaHash (not metaHash)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { val: { type: "number" } },
|
||||
@@ -386,7 +390,8 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("bootstrap is idempotent across putSchema calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
await putSchema(store, { type: "string" });
|
||||
await putSchema(store, { type: "number" });
|
||||
|
||||
@@ -142,7 +142,11 @@ export async function putSchema(
|
||||
store: Store,
|
||||
jsonSchema: JSONSchema,
|
||||
): Promise<Hash> {
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) {
|
||||
throw new Error("Meta-schema not found in bootstrap result");
|
||||
}
|
||||
if (!isValidSchema(jsonSchema)) {
|
||||
throw new SchemaValidationError(
|
||||
"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema",
|
||||
|
||||
@@ -1593,3 +1593,186 @@ describe("VariableStore - Tag/Label Management", () => {
|
||||
varStore.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// @ Prefix Support for Variable Names
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("VariableStore - @ Prefix Variable Names", () => {
|
||||
let store: Store;
|
||||
let dbPath: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (dbPath) {
|
||||
try {
|
||||
unlinkSync(dbPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("should accept variable name with @ prefix in first segment", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test value");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// Should succeed
|
||||
const variable = varStore.set("@ucas/test/foo", hash);
|
||||
expect(variable.name).toBe("@ucas/test/foo");
|
||||
|
||||
const retrieved = varStore.get("@ucas/test/foo", schemaHash);
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.name).toBe("@ucas/test/foo");
|
||||
expect(retrieved?.value).toBe(hash);
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should accept variable name starting with @", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "config value");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// Single segment with @
|
||||
varStore.set("@config", hash);
|
||||
const result = varStore.get("@config", schemaHash);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe("@config");
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should accept complex @ prefix paths", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// Multiple valid patterns
|
||||
const validNames = [
|
||||
"@ucas/render/template",
|
||||
"@system/config",
|
||||
"@foo.bar/baz",
|
||||
"@app-1/test_2",
|
||||
];
|
||||
|
||||
for (const name of validNames) {
|
||||
expect(() => varStore.set(name, hash)).not.toThrow();
|
||||
const retrieved = varStore.get(name, schemaHash);
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.name).toBe(name);
|
||||
}
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should reject @ in non-first segment", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// @ only allowed at start of entire name
|
||||
const invalidNames = [
|
||||
"foo/@bar", // @ in second segment
|
||||
"foo/bar/@baz", // @ in third segment
|
||||
"foo@bar", // @ within segment (not at start)
|
||||
];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError);
|
||||
}
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should reject @ followed by invalid characters", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// @ prefix must still follow segment rules after @
|
||||
const invalidNames = [
|
||||
"@", // @ alone is empty segment
|
||||
"@/foo", // empty after @
|
||||
"@foo bar", // space not allowed
|
||||
"@foo$bar", // $ not allowed
|
||||
];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError);
|
||||
}
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should still accept all previously valid names", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// All non-@ names should continue to work
|
||||
const validNames = [
|
||||
"simple",
|
||||
"with.dots",
|
||||
"with-dashes",
|
||||
"with_underscores",
|
||||
"path/to/var",
|
||||
"foo.bar/baz-qux/test_123",
|
||||
];
|
||||
|
||||
for (const name of validNames) {
|
||||
expect(() => varStore.set(name, hash)).not.toThrow();
|
||||
}
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should still reject previously invalid names", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
const invalidNames = [
|
||||
"", // empty
|
||||
"/leading", // leading slash
|
||||
"trailing/", // trailing slash
|
||||
"double//slash", // empty segment
|
||||
"has space", // space
|
||||
"has$dollar", // special char
|
||||
];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError);
|
||||
}
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,7 @@ export class VariableStore {
|
||||
|
||||
/**
|
||||
* Validate variable name format
|
||||
* @ is allowed at the start of the first segment (system-reserved)
|
||||
*/
|
||||
private validateName(name: string): void {
|
||||
// Rule 1: Cannot be empty
|
||||
@@ -139,9 +140,10 @@ export class VariableStore {
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ and no empty segments
|
||||
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment)
|
||||
const segments = name.split("/");
|
||||
for (const segment of segments) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i] as string;
|
||||
if (segment === "") {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
@@ -150,10 +152,12 @@ export class VariableStore {
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
|
||||
// First segment can start with @, all segments can contain [a-zA-Z0-9._-]
|
||||
const regex = i === 0 ? /^@?[a-zA-Z0-9._-]+$/ : /^[a-zA-Z0-9._-]+$/;
|
||||
if (!regex.test(segment)) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
`Segment "${segment}" contains invalid characters (only ${i === 0 ? "@, " : ""}a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import type { CasNode } from "../src/types.js";
|
||||
describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
test("1.1: Meta-schema is a valid JSON Schema", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
@@ -25,7 +26,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.2: Meta-schema self-validates", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
@@ -34,7 +36,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.3: Meta-schema defines all supported keywords", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
@@ -57,7 +60,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.4: Meta-schema does not include unsupported keywords", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
@@ -74,7 +78,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.5: Meta-schema node type equals its own hash", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
@@ -443,7 +448,8 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
test("5.1: Bootstrap hash changes (breaking change)", async () => {
|
||||
// This is a documentation test - the old hash was different
|
||||
const store = new MemStore();
|
||||
const newMetaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const newMetaHash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
// The new hash should be different from the old system metadata hash
|
||||
// We just verify it's a valid hash format
|
||||
@@ -585,7 +591,8 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||
test("7.1: Meta-schema allows recursive schema definitions", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user