Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a445f2ecc5 | |||
| 2932aa5980 | |||
| a0d7b67923 | |||
| 7b29fe777c | |||
| 64b8a88bdc | |||
| 4717024e9b | |||
| 1e5f4b7c46 | |||
| 0a761f5289 | |||
| 07e08e3b38 | |||
| e0af351991 | |||
| 72f85c9077 | |||
| cccfca3137 |
@@ -30,6 +30,7 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
"liquidjs": "^10.27.0",
|
||||
"xxhash-wasm": "^1.1.0",
|
||||
},
|
||||
},
|
||||
@@ -141,6 +142,8 @@
|
||||
|
||||
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
|
||||
|
||||
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="],
|
||||
@@ -203,6 +206,8 @@
|
||||
|
||||
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
|
||||
|
||||
"liquidjs": ["liquidjs@10.27.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w=="],
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
|
||||
|
||||
@@ -314,3 +314,414 @@ describe("ucas render command", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 6: CLI Integration with Templates", () => {
|
||||
test("6.1 CLI with Template (Default Parameters)", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
// Initialize store
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Create schema
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Create node
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice" }));
|
||||
const { stdout: nodeHash } = await runCli(
|
||||
["put", schemaHash.trim(), nodeFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Create template file (JSON-encoded string)
|
||||
const templateFile = join(tmpStore, "template.json");
|
||||
writeFileSync(templateFile, JSON.stringify("Hello {{ payload.name }}!"));
|
||||
const { stdout: tmplHash } = await runCli(
|
||||
["put", "@string", templateFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Register template
|
||||
await runCli(
|
||||
[
|
||||
"var",
|
||||
"set",
|
||||
`@ucas/template/text/${schemaHash.trim()}`,
|
||||
tmplHash.trim(),
|
||||
],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Render with template
|
||||
const { stdout: output, exitCode } = await runCli(
|
||||
["render", nodeHash.trim()],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output).toBe("Hello Alice!");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("6.2 CLI with Template + Custom Decay", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Create schema with child ref
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
child: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Create child node
|
||||
const childFile = join(tmpStore, "child.json");
|
||||
writeFileSync(childFile, JSON.stringify({ value: "child", child: null }));
|
||||
const { stdout: childHash } = await runCli(
|
||||
["put", schemaHash.trim(), childFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Create parent node
|
||||
const parentFile = join(tmpStore, "parent.json");
|
||||
writeFileSync(
|
||||
parentFile,
|
||||
JSON.stringify({ value: "parent", child: childHash.trim() }),
|
||||
);
|
||||
const { stdout: parentHash } = await runCli(
|
||||
["put", schemaHash.trim(), parentFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Create template showing resolution (JSON-encoded string)
|
||||
const templateFile = join(tmpStore, "template.json");
|
||||
writeFileSync(
|
||||
templateFile,
|
||||
JSON.stringify("{{ payload.value }}(res={{ resolution }})"),
|
||||
);
|
||||
const { stdout: tmplHash } = await runCli(
|
||||
["put", "@string", templateFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Register template
|
||||
await runCli(
|
||||
[
|
||||
"var",
|
||||
"set",
|
||||
`@ucas/template/text/${schemaHash.trim()}`,
|
||||
tmplHash.trim(),
|
||||
],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Render with custom decay
|
||||
const { stdout: output, exitCode } = await runCli(
|
||||
["render", parentHash.trim(), "--decay", "0.7"],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output).toContain("parent(res=1)");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("6.3 CLI with Template + All Parameters", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Bob" }));
|
||||
const { stdout: nodeHash } = await runCli(
|
||||
["put", schemaHash.trim(), nodeFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Create template (JSON-encoded string)
|
||||
const templateFile = join(tmpStore, "template.json");
|
||||
writeFileSync(
|
||||
templateFile,
|
||||
JSON.stringify("Greetings {{ payload.name }}!"),
|
||||
);
|
||||
const { stdout: tmplHash } = await runCli(
|
||||
["put", "@string", templateFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
await runCli(
|
||||
[
|
||||
"var",
|
||||
"set",
|
||||
`@ucas/template/text/${schemaHash.trim()}`,
|
||||
tmplHash.trim(),
|
||||
],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
const { stdout: output, exitCode } = await runCli(
|
||||
[
|
||||
"render",
|
||||
nodeHash.trim(),
|
||||
"--resolution",
|
||||
"0.8",
|
||||
"--decay",
|
||||
"0.6",
|
||||
"--epsilon",
|
||||
"0.005",
|
||||
],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output).toBe("Greetings Bob!");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("6.4 CLI with Non-templated Node (YAML Fallback)", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" }));
|
||||
const { stdout: nodeHash } = await runCli(
|
||||
["put", schemaHash.trim(), nodeFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// No template registered - should fall back to YAML
|
||||
const { stdout: output, exitCode } = await runCli(
|
||||
["render", nodeHash.trim()],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output).toContain("name:");
|
||||
expect(output).toContain("Charlie");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("6.5 CLI Error: Invalid Decay Value", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Test" }));
|
||||
const { stdout: nodeHash } = await runCli(
|
||||
["put", schemaHash.trim(), nodeFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["render", nodeHash.trim(), "--decay", "1.5"],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("decay");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Store validation - non-existent paths (Issue #55)", () => {
|
||||
test("E2E-55-1: get with non-existent store reports store not found", async () => {
|
||||
const fakePath = "/nonexistent/path/to/store";
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["get", "AAAAAAAAAAAAA"],
|
||||
fakePath,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
expect(stderr).toContain(fakePath);
|
||||
expect(stderr).not.toContain("Node not found");
|
||||
});
|
||||
|
||||
test("E2E-55-2: has with non-existent store reports store not found", async () => {
|
||||
const fakePath = `/tmp/nonexistent-store-${Date.now()}`;
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["has", "BBBBBBBBBBBBB"],
|
||||
fakePath,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-3: verify with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["verify", "CCCCCCCCCCCCC"],
|
||||
"/does/not/exist",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-4: schema get with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["schema", "get", "@string"],
|
||||
"/fake/path",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-5: schema list with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["schema", "list"],
|
||||
"/nonexistent/store",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-6: walk with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["walk", "DDDDDDDDDDDDD"],
|
||||
"/tmp/missing-store",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-7: cat with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["cat", "EEEEEEEEEEEEE"],
|
||||
"/no/such/dir",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-8: refs with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["refs", "FFFFFFFFFFFFF"],
|
||||
"/nonexistent",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-9: existing store works normally (regression)", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const payloadFile = join(tmpStore, "test.json");
|
||||
writeFileSync(payloadFile, JSON.stringify("test"));
|
||||
|
||||
const { stdout: hash } = await runCli(
|
||||
["put", "@string", payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
const { exitCode, stdout, stderr } = await runCli(
|
||||
["get", hash.trim()],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
const node = JSON.parse(stdout);
|
||||
expect(node).toHaveProperty("type");
|
||||
expect(node).toHaveProperty("payload", "test");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("E2E-55-10: init creates directory (should not validate)", async () => {
|
||||
const newStore = join(tmpdir(), `new-store-${Date.now()}`);
|
||||
try {
|
||||
const { exitCode, stdout, stderr } = await runCli(["init"], newStore);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
const { existsSync } = await import("node:fs");
|
||||
expect(existsSync(newStore)).toBe(true);
|
||||
} finally {
|
||||
rmSync(newStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
InvalidVariableNameError,
|
||||
putSchema,
|
||||
refs,
|
||||
render,
|
||||
renderAsync,
|
||||
renderDirect,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
validate,
|
||||
@@ -110,12 +111,26 @@ function readJsonFile(file: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function openStore(): Store {
|
||||
return createFsStore(resolve(storePath));
|
||||
/**
|
||||
* Validate that the store directory exists.
|
||||
* Dies with a clear error message if not found.
|
||||
*/
|
||||
function validateStoreExists(path: string): void {
|
||||
if (!existsSync(path)) {
|
||||
die(`Store not found at ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openStore(shouldCreate = false): Store {
|
||||
const fullPath = resolve(storePath);
|
||||
if (!shouldCreate) {
|
||||
validateStoreExists(fullPath);
|
||||
}
|
||||
return createFsStore(fullPath);
|
||||
}
|
||||
|
||||
function openVarStore(): VariableStore {
|
||||
const store = openStore();
|
||||
const store = openStore(true);
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
return createVariableStore(resolve(varDbPath), store);
|
||||
}
|
||||
@@ -125,9 +140,12 @@ function openVarStore(): VariableStore {
|
||||
* If the input starts with @, resolve it via bootstrap
|
||||
* Otherwise, return the hash as-is
|
||||
*/
|
||||
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
|
||||
async function resolveTypeHash(
|
||||
typeHashOrAlias: string,
|
||||
shouldCreate = false,
|
||||
): Promise<Hash> {
|
||||
if (typeHashOrAlias.startsWith("@")) {
|
||||
const store = openStore();
|
||||
const store = openStore(shouldCreate);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const resolvedHash = builtinSchemas[typeHashOrAlias];
|
||||
if (!resolvedHash) {
|
||||
@@ -231,7 +249,7 @@ async function cmdInit(): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdBootstrap(): Promise<void> {
|
||||
const store = openStore();
|
||||
const store = openStore(true);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
console.log(metaHash);
|
||||
@@ -241,7 +259,7 @@ async function cmdSchemaPut(args: string[]): Promise<void> {
|
||||
const file = args[0];
|
||||
if (!file) die("Usage: json-cas schema put <file.json>");
|
||||
const schema = readJsonFile(file) as JSONSchema;
|
||||
const store = openStore();
|
||||
const store = openStore(true);
|
||||
const hash = await putSchema(store, schema);
|
||||
console.log(hash);
|
||||
}
|
||||
@@ -290,9 +308,9 @@ async function cmdPut(args: string[]): Promise<void> {
|
||||
const file = args[1];
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas put <type-hash> <file.json>");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
|
||||
const payload = readJsonFile(file);
|
||||
const store = openStore();
|
||||
const store = openStore(true);
|
||||
const hash = await store.put(typeHash, payload);
|
||||
console.log(hash);
|
||||
}
|
||||
@@ -379,17 +397,23 @@ async function cmdHash(args: string[]): Promise<void> {
|
||||
const file = args[1];
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas hash <type-hash> <file.json>");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
|
||||
const payload = readJsonFile(file);
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
console.log(hash);
|
||||
}
|
||||
|
||||
async function cmdRender(args: string[]): Promise<void> {
|
||||
const isPipe = flags.pipe === true || flags.p === true;
|
||||
const hash = args[0];
|
||||
if (!hash) {
|
||||
|
||||
if (isPipe && hash) {
|
||||
die("Cannot use --pipe/-p with a hash argument. Use one or the other.");
|
||||
}
|
||||
|
||||
if (!isPipe && !hash) {
|
||||
die(
|
||||
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]",
|
||||
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]\n ucas render --pipe/-p [--resolution <n>] [--decay <n>] [--epsilon <n>]",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -421,9 +445,63 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const output = render(store, hash, { resolution, decay, epsilon });
|
||||
// Output to stdout without JSON wrapping (raw YAML)
|
||||
process.stdout.write(output);
|
||||
if (isPipe) {
|
||||
// Read { type, value } JSON from stdin
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
const input = Buffer.concat(chunks).toString("utf-8").trim();
|
||||
if (!input) {
|
||||
die("No input on stdin. Pipe a { type, value } JSON envelope.");
|
||||
}
|
||||
|
||||
let envelope: { type: string; value: unknown };
|
||||
try {
|
||||
envelope = JSON.parse(input) as { type: string; value: unknown };
|
||||
} catch {
|
||||
die("Invalid JSON on stdin. Expected { type, value } envelope.");
|
||||
return; // unreachable, for TS
|
||||
}
|
||||
|
||||
if (
|
||||
typeof envelope !== "object" ||
|
||||
envelope === null ||
|
||||
typeof envelope.type !== "string" ||
|
||||
!("value" in envelope)
|
||||
) {
|
||||
die("Invalid envelope. Expected { type: string, value: unknown }.");
|
||||
}
|
||||
|
||||
// Validate type hash format: 13-char uppercase Crockford Base32
|
||||
if (!/^[0-9A-Z]{13}$/.test(envelope.type)) {
|
||||
die(
|
||||
`Invalid type hash: "${envelope.type}". Expected 13-character uppercase Crockford Base32 string.`,
|
||||
);
|
||||
}
|
||||
|
||||
const output = renderDirect(
|
||||
envelope.type as Hash,
|
||||
envelope.value,
|
||||
store,
|
||||
{
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
},
|
||||
);
|
||||
process.stdout.write(output);
|
||||
} else {
|
||||
const varStore = openVarStore();
|
||||
const output = await renderAsync(store, hash, {
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
varStore,
|
||||
});
|
||||
// Output to stdout without JSON wrapping (raw output)
|
||||
process.stdout.write(output);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
die(error.message);
|
||||
@@ -829,6 +907,7 @@ Commands:
|
||||
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
|
||||
render --pipe/-p [options] Render { type, value } from stdin
|
||||
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
|
||||
@@ -850,7 +929,8 @@ Flags:
|
||||
--inline <text> Inline text content for template set
|
||||
--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)`);
|
||||
--epsilon <n> Cutoff threshold for render (default: 0.01)
|
||||
--pipe, -p Read { type, value } JSON from stdin for render`);
|
||||
}
|
||||
|
||||
// ---- Dispatch ----
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
"liquidjs": "^10.27.0",
|
||||
"xxhash-wasm": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ 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 { renderWithTemplate } from "./liquid-render.js";
|
||||
export {
|
||||
type RenderOptions,
|
||||
render,
|
||||
renderAsync,
|
||||
renderDirect,
|
||||
} from "./render.js";
|
||||
export type { JSONSchema } from "./schema.js";
|
||||
export {
|
||||
getSchema,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
||||
import { type Context, Liquid, type TagToken } from "liquidjs";
|
||||
import type { RenderOptions } from "./render.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
|
||||
const DEFAULT_RESOLUTION = 1.0;
|
||||
const DEFAULT_DECAY = 0.5;
|
||||
const DEFAULT_EPSILON = 0.01;
|
||||
const FLOAT_TOLERANCE = 1e-10;
|
||||
|
||||
/**
|
||||
* Render a CAS node using LiquidJS templates with resolution-based decay.
|
||||
* Templates are discovered via variables: @ucas/template/text/<type-hash>
|
||||
*/
|
||||
export async function renderWithTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): Promise<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>();
|
||||
|
||||
// Create Liquid engine
|
||||
const engine = createLiquidEngine(store, varStore, decay);
|
||||
|
||||
return await renderNode(
|
||||
engine,
|
||||
store,
|
||||
varStore,
|
||||
hash,
|
||||
resolution,
|
||||
epsilon,
|
||||
visited,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Liquid engine instance with custom render tag
|
||||
*/
|
||||
function createLiquidEngine(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
globalDecay: number,
|
||||
): Liquid {
|
||||
const engine = new Liquid({
|
||||
strictFilters: false,
|
||||
strictVariables: false,
|
||||
});
|
||||
|
||||
// Type for storing parsed tag data
|
||||
type RenderTagState = {
|
||||
variable: string;
|
||||
decay: number | undefined;
|
||||
};
|
||||
|
||||
// Register custom {% render %} tag
|
||||
// Capture store, varStore, globalDecay in closure
|
||||
engine.registerTag("render", {
|
||||
parse(token: TagToken) {
|
||||
// Parse "variable" or "variable, decay: 0.7" syntax
|
||||
const args = token.args.trim();
|
||||
const match = args.match(/^(\S+)(?:,\s*decay:\s*([\d.]+))?$/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid render tag syntax: ${args}. Expected: {% render variable %} or {% render variable, decay: 0.7 %}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Store parsed values on the tag instance
|
||||
const state = this as unknown as RenderTagState;
|
||||
state.variable = match[1] as string;
|
||||
state.decay = match[2] ? Number.parseFloat(match[2]) : undefined;
|
||||
|
||||
// Validate decay if provided
|
||||
if (state.decay !== undefined) {
|
||||
if (state.decay <= 0 || state.decay > 1) {
|
||||
throw new Error("decay must be in (0, 1]");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async render(ctxLiquid: Context) {
|
||||
// Access parsed values
|
||||
const state = this as unknown as RenderTagState;
|
||||
const variable = state.variable;
|
||||
const explicitDecay = state.decay;
|
||||
|
||||
// Resolve the variable to a hash (split on dots for nested paths)
|
||||
const variablePath = variable.split(".");
|
||||
const value = ctxLiquid.get(variablePath);
|
||||
|
||||
// Handle null/undefined - render as empty
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle non-string values - render as empty
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const nodeHash = value as Hash;
|
||||
|
||||
// Get current render context
|
||||
const currentResolution = ctxLiquid.get(["resolution"]) as number;
|
||||
const currentEpsilon = ctxLiquid.get(["epsilon"]) as number;
|
||||
|
||||
// Compute child resolution using decay priority:
|
||||
// 1. Template explicit decay (explicitDecay)
|
||||
// 2. Global decay (from CLI/options)
|
||||
// 3. Engine default (0.5)
|
||||
const effectiveDecay =
|
||||
explicitDecay !== undefined
|
||||
? explicitDecay
|
||||
: (globalDecay ?? DEFAULT_DECAY);
|
||||
const childResolution = currentResolution * effectiveDecay;
|
||||
|
||||
// Recursively render the referenced node
|
||||
const visited = ctxLiquid.get(["__visited"]) as Set<Hash>;
|
||||
const output = await renderNode(
|
||||
engine,
|
||||
store,
|
||||
varStore,
|
||||
nodeHash,
|
||||
childResolution,
|
||||
currentEpsilon,
|
||||
visited,
|
||||
);
|
||||
|
||||
return output;
|
||||
},
|
||||
});
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single node with template or fallback to cas: reference
|
||||
*/
|
||||
async function renderNode(
|
||||
engine: Liquid,
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
hash: Hash,
|
||||
currentResolution: number,
|
||||
epsilon: number,
|
||||
visited: Set<Hash>,
|
||||
): Promise<string> {
|
||||
// Check if resolution is below threshold
|
||||
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
|
||||
// Fetch the node
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
|
||||
// Cycle detection
|
||||
if (visited.has(hash)) {
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
visited.add(hash);
|
||||
|
||||
try {
|
||||
// Try to find a template for this node's type
|
||||
const template = await findTemplate(store, varStore, node.type);
|
||||
|
||||
if (template === null) {
|
||||
// No template found - this is handled by the caller (fallback to YAML)
|
||||
// For now, return a simple representation
|
||||
visited.delete(hash);
|
||||
return renderFallback(store, node.payload);
|
||||
}
|
||||
|
||||
// Render using the template
|
||||
const context = {
|
||||
resolution: currentResolution,
|
||||
epsilon,
|
||||
hash,
|
||||
payload: node.payload,
|
||||
type: node.type,
|
||||
timestamp: node.timestamp,
|
||||
__visited: visited, // Pass visited set through context
|
||||
};
|
||||
|
||||
const output = await engine.parseAndRender(template, context);
|
||||
|
||||
visited.delete(hash);
|
||||
return output;
|
||||
} catch (error) {
|
||||
visited.delete(hash);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a template for a given type hash
|
||||
*/
|
||||
async function findTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
typeHash: Hash,
|
||||
): Promise<string | null> {
|
||||
const varName = `@ucas/template/text/${typeHash}`;
|
||||
|
||||
try {
|
||||
// Find the string schema hash (we need this to query variables)
|
||||
const stringSchema = await putSchema(store, { type: "string" });
|
||||
|
||||
const variable = varStore.get(varName, stringSchema);
|
||||
if (variable === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templateNode = store.get(variable.value);
|
||||
if (templateNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Template should be a string
|
||||
if (typeof templateNode.payload !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return templateNode.payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback renderer for nodes without templates
|
||||
*/
|
||||
function renderFallback(_store: Store, payload: unknown): string {
|
||||
// Simple YAML-like representation
|
||||
if (payload === null) {
|
||||
return "null\n";
|
||||
}
|
||||
|
||||
if (typeof payload === "string") {
|
||||
return `${payload}\n`;
|
||||
}
|
||||
|
||||
if (typeof payload === "number" || typeof payload === "boolean") {
|
||||
return `${payload}\n`;
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
if (payload.length === 0) {
|
||||
return "[]\n";
|
||||
}
|
||||
return `- ${payload.join("\n- ")}\n`;
|
||||
}
|
||||
|
||||
if (typeof payload === "object") {
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) {
|
||||
return "{}\n";
|
||||
}
|
||||
const pairs = keys.map((key) => `${key}: ${obj[key]}`);
|
||||
return `${pairs.join("\n")}\n`;
|
||||
}
|
||||
|
||||
return "null\n";
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { render } from "./render.js";
|
||||
import { render, renderDirect } from "./render.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Hash } from "./types.js";
|
||||
@@ -933,3 +933,125 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
expect(output).toContain("🌍");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 9: renderDirect (in-memory rendering)", () => {
|
||||
test("9.1 Render primitive value without store", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, "hello world", null, null);
|
||||
expect(output.trim()).toBe("hello world");
|
||||
});
|
||||
|
||||
test("9.2 Render object value without store", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(
|
||||
fakeTypeHash,
|
||||
{
|
||||
name: "Alice",
|
||||
age: 30,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(output).toContain("name: Alice");
|
||||
expect(output).toContain("age: 30");
|
||||
});
|
||||
|
||||
test("9.3 Render array value without store", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, ["a", "b", "c"], null, null);
|
||||
expect(output).toContain("-");
|
||||
expect(output).toContain("a");
|
||||
expect(output).toContain("b");
|
||||
expect(output).toContain("c");
|
||||
});
|
||||
|
||||
test("9.4 Render nested object without store", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(
|
||||
fakeTypeHash,
|
||||
{
|
||||
user: { name: "Bob", role: "admin" },
|
||||
active: true,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(output).toContain("name: Bob");
|
||||
expect(output).toContain("role: admin");
|
||||
expect(output).toContain("active: true");
|
||||
});
|
||||
|
||||
test("9.5 Render with store expands cas_ref fields", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Create a child node
|
||||
const childSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { msg: { type: "string" } },
|
||||
});
|
||||
const childHash = await store.put(childSchema, { msg: "inner" });
|
||||
|
||||
// Parent schema with cas_ref
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
child: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
|
||||
// Render directly with store — cas_ref should expand
|
||||
const output = renderDirect(
|
||||
parentSchema,
|
||||
{ child: childHash },
|
||||
store,
|
||||
null,
|
||||
);
|
||||
expect(output).toContain("msg: inner");
|
||||
});
|
||||
|
||||
test("9.6 Render with resolution/decay options", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, { key: "value" }, null, {
|
||||
resolution: 0.5,
|
||||
decay: 0.8,
|
||||
});
|
||||
expect(output).toContain("key: value");
|
||||
});
|
||||
|
||||
test("9.7 Validate parameters", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
expect(() =>
|
||||
renderDirect(fakeTypeHash, "x", null, { resolution: 2 }),
|
||||
).toThrow("resolution must be in [0, 1]");
|
||||
expect(() => renderDirect(fakeTypeHash, "x", null, { decay: 0 })).toThrow(
|
||||
"decay must be in (0, 1]",
|
||||
);
|
||||
expect(() =>
|
||||
renderDirect(fakeTypeHash, "x", null, { epsilon: -1 }),
|
||||
).toThrow("epsilon must be >= 0");
|
||||
});
|
||||
|
||||
test("9.8 Render null value", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, null, null, null);
|
||||
expect(output.trim()).toBe("null");
|
||||
});
|
||||
|
||||
test("9.9 cas_ref without store renders as cas: reference", () => {
|
||||
// Without store, can't identify cas_ref fields — hash strings stay as strings
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const someHash = "ABCDEFGH12345" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, { ref: someHash }, null, null);
|
||||
// Without store, it's just a string value
|
||||
expect(output).toContain(`ref: ${someHash}`);
|
||||
});
|
||||
|
||||
test("9.10 store present but schema missing — renders without ref expansion", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const unknownType = "ZZZZZZZZZZZZ0" as Hash;
|
||||
const output = renderDirect(unknownType, { key: "val" }, store, null);
|
||||
expect(output).toContain("key: val");
|
||||
});
|
||||
});
|
||||
|
||||
+124
-14
@@ -1,10 +1,13 @@
|
||||
import { refs } from "./schema.js";
|
||||
import { renderWithTemplate } from "./liquid-render.js";
|
||||
import { collectRefs, getSchema, putSchema, refs } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
|
||||
export type RenderOptions = {
|
||||
resolution?: number; // (0, 1], default 1.0
|
||||
decay?: number; // (0, 1], default 0.5
|
||||
epsilon?: number; // >= 0, default 0.01
|
||||
varStore?: VariableStore; // Optional: for template lookup
|
||||
};
|
||||
|
||||
const DEFAULT_RESOLUTION = 1.0;
|
||||
@@ -14,19 +17,18 @@ const DEFAULT_EPSILON = 0.01;
|
||||
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.
|
||||
* Extract and validate resolution/decay/epsilon from options.
|
||||
*/
|
||||
export function render(
|
||||
store: Store,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): string {
|
||||
function validateAndExtractOptions(
|
||||
options:
|
||||
| Pick<RenderOptions, "resolution" | "decay" | "epsilon">
|
||||
| null
|
||||
| undefined,
|
||||
): { resolution: number; decay: number; epsilon: number } {
|
||||
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]");
|
||||
}
|
||||
@@ -37,14 +39,122 @@ export function render(
|
||||
throw new Error("epsilon must be >= 0");
|
||||
}
|
||||
|
||||
const visited = new Set<Hash>();
|
||||
return { resolution, decay, epsilon };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a CAS node as YAML with resolution-based decay.
|
||||
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
|
||||
* This is the synchronous version without template support.
|
||||
* For template support, use renderAsync() with varStore.
|
||||
*/
|
||||
export function render(
|
||||
store: Store,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): string {
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
const visited = new Set<Hash>();
|
||||
return renderNode(store, hash, resolution, decay, epsilon, visited);
|
||||
}
|
||||
|
||||
function renderNode(
|
||||
/**
|
||||
* Async render with LiquidJS template support.
|
||||
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
|
||||
* If varStore is provided, attempts to use LiquidJS templates first, fallback to YAML.
|
||||
*/
|
||||
export async function renderAsync(
|
||||
store: Store,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): Promise<string> {
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
const varStore = options?.varStore;
|
||||
|
||||
// If varStore provided, try template rendering first
|
||||
if (varStore !== undefined) {
|
||||
try {
|
||||
const node = store.get(hash);
|
||||
if (node !== null) {
|
||||
// Check if a template exists for this type
|
||||
const templateExists = await hasTemplate(store, varStore, node.type);
|
||||
if (templateExists) {
|
||||
return await renderWithTemplate(store, varStore, hash, {
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to YAML rendering
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to YAML rendering
|
||||
const visited = new Set<Hash>();
|
||||
return renderNode(store, hash, resolution, decay, epsilon, visited);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a value directly (in-memory) without requiring it to be stored.
|
||||
* Accepts a raw { type, value } pair. Store is optional and read-only —
|
||||
* used only for schema lookup and expanding nested cas_ref references.
|
||||
* No data is written to the store.
|
||||
*/
|
||||
export function renderDirect(
|
||||
typeHash: Hash,
|
||||
value: unknown,
|
||||
store: Store | null,
|
||||
options: Omit<RenderOptions, "varStore"> | null,
|
||||
): string {
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
// Try to get schema from store to identify cas_ref fields
|
||||
let refSet = new Set<Hash>();
|
||||
if (store !== null) {
|
||||
const schema = getSchema(store, typeHash);
|
||||
if (schema !== null) {
|
||||
refSet = new Set(collectRefs(schema, value));
|
||||
}
|
||||
}
|
||||
|
||||
const childResolution = resolution * decay;
|
||||
const visited = new Set<Hash>();
|
||||
|
||||
return renderValue(
|
||||
store ?? null,
|
||||
value,
|
||||
refSet,
|
||||
childResolution,
|
||||
decay,
|
||||
epsilon,
|
||||
visited,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template exists for a given type
|
||||
*/
|
||||
async function hasTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
typeHash: Hash,
|
||||
): Promise<boolean> {
|
||||
const varName = `@ucas/template/text/${typeHash}`;
|
||||
try {
|
||||
const stringSchema = await putSchema(store, { type: "string" });
|
||||
const variable = varStore.get(varName, stringSchema);
|
||||
return variable !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNode(
|
||||
store: Store | null,
|
||||
hash: Hash,
|
||||
currentResolution: number,
|
||||
decay: number,
|
||||
epsilon: number,
|
||||
@@ -56,7 +166,7 @@ function renderNode(
|
||||
}
|
||||
|
||||
// Fetch the node
|
||||
const node = store.get(hash);
|
||||
const node = store !== null ? store.get(hash) : null;
|
||||
if (node === null) {
|
||||
// Missing node - render as cas: reference
|
||||
return `cas:${hash}`;
|
||||
@@ -69,7 +179,7 @@ function renderNode(
|
||||
visited.add(hash);
|
||||
|
||||
// Get references from this node's schema
|
||||
const nodeRefs = refs(store, node);
|
||||
const nodeRefs = store !== null ? refs(store, node) : [];
|
||||
const refSet = new Set(nodeRefs);
|
||||
|
||||
// Calculate child resolution for next level
|
||||
@@ -92,7 +202,7 @@ function renderNode(
|
||||
}
|
||||
|
||||
function renderValue(
|
||||
store: Store,
|
||||
store: Store | null,
|
||||
value: unknown,
|
||||
refHashes: Set<Hash>,
|
||||
childResolution: number,
|
||||
|
||||
@@ -186,7 +186,7 @@ export function validate(store: Store, node: CasNode): boolean {
|
||||
* Handles: direct format, anyOf (nullable refs), items (array refs),
|
||||
* properties (nested objects), and additionalProperties (record refs).
|
||||
*/
|
||||
function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
const result: Hash[] = [];
|
||||
|
||||
if (schema.format === "cas_ref") {
|
||||
|
||||
Reference in New Issue
Block a user