Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e08e3b38 | |||
| e0af351991 | |||
| 72f85c9077 | |||
| cccfca3137 | |||
| 5f2906908c | |||
| 077eaa6f6d | |||
| 7e23d911a4 | |||
| 301b05c212 | |||
| 22fce0ac66 | |||
| fddbb1549e | |||
| 109aaab9b8 | |||
| 906a6dfd1c | |||
| 5e7db0ef6b | |||
| 31f84a7ab0 | |||
| 793a5c619d | |||
| b89e31f468 | |||
| b9131c728e | |||
| cd338822f2 | |||
| 1e8ccb8962 |
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
*.d.ts.map
|
||||
*.tsbuildinfo
|
||||
.worktrees/
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"version": "0.5.3",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"json-cas": "./src/index.ts"
|
||||
"json-cas": "./src/index.ts",
|
||||
"ucas": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
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 () => {
|
||||
const pkg = await Bun.file(pkgPath).json();
|
||||
expect(pkg.bin.ucas).toBe("./src/index.ts");
|
||||
});
|
||||
|
||||
test("T2: json-cas bin entry is preserved in package.json", async () => {
|
||||
const pkg = await Bun.file(pkgPath).json();
|
||||
expect(pkg.bin["json-cas"]).toBe("./src/index.ts");
|
||||
});
|
||||
|
||||
test("T3: ucas command is executable and shows help", async () => {
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("T4: both commands point to the same entrypoint", async () => {
|
||||
const pkg = await Bun.file(pkgPath).json();
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
createVariableStore,
|
||||
gc,
|
||||
getSchema,
|
||||
InvalidScopeError,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
putSchema,
|
||||
refs,
|
||||
SchemaMismatchError,
|
||||
renderAsync,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
validate,
|
||||
@@ -32,10 +32,13 @@ type Flags = Record<string, string | boolean | string[]>;
|
||||
const VALUE_FLAGS = new Set([
|
||||
"store",
|
||||
"format",
|
||||
"scope",
|
||||
"value",
|
||||
"var-db",
|
||||
"tag",
|
||||
"schema",
|
||||
"resolution",
|
||||
"decay",
|
||||
"epsilon",
|
||||
"inline",
|
||||
]);
|
||||
|
||||
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
@@ -117,6 +120,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
|
||||
@@ -124,25 +145,23 @@ function openVarStore(): VariableStore {
|
||||
async function getVariableSchemaHash(): Promise<Hash> {
|
||||
const store = openStore();
|
||||
|
||||
// Define the Variable JSON Schema (simple version for envelope)
|
||||
// Define the Variable JSON Schema (updated for new model with composite key)
|
||||
const variableSchema: JSONSchema = {
|
||||
title: "Variable",
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
scope: { type: "string" },
|
||||
value: { type: "string" },
|
||||
name: { type: "string" },
|
||||
schema: { type: "string" },
|
||||
value: { type: "string" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
tags: { type: "object" },
|
||||
labels: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: [
|
||||
"id",
|
||||
"scope",
|
||||
"value",
|
||||
"name",
|
||||
"schema",
|
||||
"value",
|
||||
"created",
|
||||
"updated",
|
||||
"tags",
|
||||
@@ -206,14 +225,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> {
|
||||
@@ -226,17 +247,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);
|
||||
@@ -262,9 +286,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);
|
||||
@@ -349,14 +375,69 @@ 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 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);
|
||||
}
|
||||
die(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdCat(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas cat <hash>");
|
||||
@@ -370,13 +451,14 @@ async function cmdCat(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarCreate(_args: string[]): Promise<void> {
|
||||
const scope = flags.scope as string | undefined;
|
||||
const value = flags.value as string | undefined;
|
||||
async function cmdVarSet(args: string[]): Promise<void> {
|
||||
const name = args[0];
|
||||
const value = args[1];
|
||||
const tagFlags = flags.tag;
|
||||
|
||||
if (!scope) die("Usage: json-cas var create --scope <scope> --value <hash>");
|
||||
if (!value) die("Usage: json-cas var create --scope <scope> --value <hash>");
|
||||
if (!name || !value) {
|
||||
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
|
||||
@@ -391,18 +473,25 @@ async function cmdVarCreate(_args: string[]): Promise<void> {
|
||||
|
||||
// Check for conflicts in initial tags/labels
|
||||
if (deleteNames.length > 0) {
|
||||
die("Error: Cannot use deletion syntax (:name) in var create");
|
||||
die("Error: Cannot use deletion syntax (:name) in var set");
|
||||
}
|
||||
|
||||
const variable = varStore.create(scope, value, {
|
||||
tags: Object.keys(tags).length > 0 ? tags : undefined,
|
||||
labels: labels.length > 0 ? labels : undefined,
|
||||
});
|
||||
// If --tag flags are provided at all, always pass options to replace tags/labels
|
||||
// If no --tag flags, pass undefined to preserve existing tags/labels
|
||||
const options =
|
||||
tagArgs.length > 0
|
||||
? {
|
||||
tags: Object.keys(tags).length > 0 ? tags : {},
|
||||
labels: labels.length > 0 ? labels : [],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const variable = varStore.set(name, value, options);
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof InvalidScopeError ||
|
||||
e instanceof InvalidVariableNameError ||
|
||||
e instanceof CasNodeNotFoundError ||
|
||||
e instanceof TagLabelConflictError
|
||||
) {
|
||||
@@ -415,15 +504,19 @@ async function cmdVarCreate(_args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdVarGet(args: string[]): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) die("Usage: json-cas var get <id>");
|
||||
const name = args[0];
|
||||
const schema = flags.schema as string | undefined;
|
||||
|
||||
if (!name || !schema) {
|
||||
die("Usage: json-cas var get <name> --schema <hash>");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
const variable = varStore.get(id);
|
||||
const variable = varStore.get(name, schema);
|
||||
if (variable === null) {
|
||||
die(`Error: Variable not found: ${id}`);
|
||||
die(`Error: Variable not found: name=${name}, schema=${schema}`);
|
||||
}
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
@@ -432,44 +525,28 @@ async function cmdVarGet(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarUpdate(args: string[]): Promise<void> {
|
||||
const id = args[0];
|
||||
const value = args[1];
|
||||
|
||||
if (!id || !value) {
|
||||
die("Usage: json-cas var update <id> <hash>");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
const variable = varStore.update(id, value);
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof VariableNotFoundError ||
|
||||
e instanceof SchemaMismatchError ||
|
||||
e instanceof CasNodeNotFoundError
|
||||
) {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarDelete(args: string[]): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) die("Usage: json-cas var delete <id>");
|
||||
const name = args[0];
|
||||
const schema = flags.schema as string | undefined;
|
||||
|
||||
if (!name) {
|
||||
die("Usage: json-cas var delete <name> [--schema <hash>]");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
const variable = varStore.delete(id);
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
if (schema !== undefined) {
|
||||
// Precise deletion: remove specific (name, schema) variant
|
||||
const variable = varStore.remove(name, schema);
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
} else {
|
||||
// Batch deletion: remove all variants for this name
|
||||
const variables = varStore.remove(name);
|
||||
const envelope = await wrapVariableEnvelope(variables);
|
||||
out(envelope);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof VariableNotFoundError) {
|
||||
die(`Error: ${e.message}`);
|
||||
@@ -481,12 +558,16 @@ async function cmdVarDelete(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdVarTag(args: string[]): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) die("Usage: json-cas var tag <id> <tag>...");
|
||||
const name = args[0];
|
||||
const schema = flags.schema as string | undefined;
|
||||
|
||||
if (!name || !schema) {
|
||||
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
|
||||
}
|
||||
|
||||
const tagArgs = args.slice(1);
|
||||
if (tagArgs.length === 0) {
|
||||
die("Usage: json-cas var tag <id> <tag>...");
|
||||
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
@@ -494,7 +575,7 @@ async function cmdVarTag(args: string[]): Promise<void> {
|
||||
try {
|
||||
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
|
||||
|
||||
const variable = varStore.tag(id, {
|
||||
const variable = varStore.tag(name, schema, {
|
||||
add: Object.keys(tags).length > 0 ? tags : undefined,
|
||||
addLabels: labels.length > 0 ? labels : undefined,
|
||||
delete: deleteNames.length > 0 ? deleteNames : undefined,
|
||||
@@ -516,8 +597,9 @@ async function cmdVarTag(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarList(_args: string[]): Promise<void> {
|
||||
const scope = (flags.scope as string | undefined) ?? "";
|
||||
async function cmdVarList(args: string[]): Promise<void> {
|
||||
const namePrefix = args[0] ?? "";
|
||||
const schema = flags.schema as string | undefined;
|
||||
const tagFlags = flags.tag;
|
||||
|
||||
const varStore = openVarStore();
|
||||
@@ -537,14 +619,15 @@ async function cmdVarList(_args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
const variables = varStore.list({
|
||||
scope,
|
||||
namePrefix,
|
||||
schema,
|
||||
tags: Object.keys(tags).length > 0 ? tags : undefined,
|
||||
labels: labels.length > 0 ? labels : undefined,
|
||||
});
|
||||
const envelope = await wrapVariableEnvelope(variables);
|
||||
out(envelope);
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidScopeError) {
|
||||
if (e instanceof InvalidVariableNameError) {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
@@ -553,6 +636,174 @@ async function cmdVarList(_args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateSet(args: string[]): Promise<void> {
|
||||
const schemaHash = args[0];
|
||||
const inlineFlag = flags.inline;
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
// Validate schema hash exists in CAS
|
||||
if (!store.has(schemaHash)) {
|
||||
die(`Error: Schema hash not found in CAS: ${schemaHash}`);
|
||||
}
|
||||
|
||||
// Determine content source
|
||||
let content: string;
|
||||
|
||||
if (typeof inlineFlag === "string") {
|
||||
// --inline mode
|
||||
const fileArg = args[1];
|
||||
if (fileArg !== undefined && !fileArg.startsWith("--")) {
|
||||
die("Error: Cannot specify both file and --inline");
|
||||
}
|
||||
content = inlineFlag;
|
||||
} else if (inlineFlag === true) {
|
||||
// --inline flag present but no value
|
||||
const contentArg = args[1];
|
||||
if (!contentArg) {
|
||||
die(
|
||||
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
|
||||
);
|
||||
}
|
||||
content = contentArg;
|
||||
} else {
|
||||
// File mode
|
||||
const file = args[1];
|
||||
if (!file) {
|
||||
die(
|
||||
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
|
||||
);
|
||||
}
|
||||
if (!existsSync(file)) {
|
||||
die(`Error: File not found: ${file}`);
|
||||
}
|
||||
content = readFileSync(file, "utf-8");
|
||||
}
|
||||
|
||||
// Store content in CAS under @string schema
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
const contentHash = await store.put(stringHash, content);
|
||||
|
||||
// Create variable binding: @ucas/template/text/<schema-hash>
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
varStore.set(varName, contentHash);
|
||||
|
||||
out({
|
||||
schemaHash,
|
||||
contentHash,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CasNodeNotFoundError) {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateGet(args: string[]): Promise<void> {
|
||||
const schemaHash = args[0];
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template get <schema-hash>");
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
const variable = varStore.get(varName, stringHash);
|
||||
|
||||
if (variable === null) {
|
||||
die(`Error: Template not found for schema: ${schemaHash}`);
|
||||
}
|
||||
|
||||
// Get the content from CAS
|
||||
const node = store.get(variable.value);
|
||||
if (node === null) {
|
||||
die(`Error: Content not found in CAS: ${variable.value}`);
|
||||
}
|
||||
|
||||
// Output raw text (not JSON)
|
||||
process.stdout.write(node.payload as string);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateList(_args: string[]): Promise<void> {
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
const variables = varStore.list({
|
||||
namePrefix: "@ucas/template/text/",
|
||||
schema: stringHash,
|
||||
});
|
||||
|
||||
const templates = variables.map((v) => {
|
||||
const schemaHash = v.name.replace("@ucas/template/text/", "");
|
||||
|
||||
// Get content for preview
|
||||
const node = store.get(v.value);
|
||||
const content = (node?.payload as string | undefined) ?? "";
|
||||
|
||||
// Truncate preview to 80 chars
|
||||
const preview =
|
||||
content.length > 80 ? `${content.slice(0, 77)}...` : content;
|
||||
|
||||
return {
|
||||
schemaHash,
|
||||
preview,
|
||||
};
|
||||
});
|
||||
|
||||
out(templates);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateDelete(args: string[]): Promise<void> {
|
||||
const schemaHash = args[0];
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template delete <schema-hash>");
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
varStore.remove(varName, stringHash);
|
||||
|
||||
out({ deleted: true });
|
||||
} catch (e) {
|
||||
if (e instanceof VariableNotFoundError) {
|
||||
die(`Error: Template not found for schema: ${schemaHash}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdGc(_args: string[]): Promise<void> {
|
||||
const store = createFsStore(storePath);
|
||||
const varStore = createVariableStore(varDbPath, store);
|
||||
@@ -583,20 +834,29 @@ 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 create --scope <s> --value <h> [--tag <tag>...] Create a variable
|
||||
var get <id> Get a variable by ID
|
||||
var update <id> <hash> Update variable value
|
||||
var delete <id> Delete a variable
|
||||
var tag <id> <tag>... Add/update/delete tags and labels
|
||||
var list [--scope <prefix>] [--tag <tag>...] List variables (filter by scope/tags/labels)
|
||||
var set <name> <hash> [--tag <tag>...] Create/update a variable
|
||||
var get <name> --schema <hash> Get a variable by name + schema
|
||||
var delete <name> [--schema <hash>] Delete variable(s)
|
||||
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables
|
||||
var tag <name> --schema <hash> <operations...> Modify tags/labels
|
||||
template set <schema-hash> <file> | --inline <text> Set template for schema
|
||||
template get <schema-hash> Get template content as raw text
|
||||
template list List all templates
|
||||
template delete <schema-hash> Delete template for schema
|
||||
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
|
||||
--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)
|
||||
--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)`);
|
||||
}
|
||||
|
||||
// ---- Dispatch ----
|
||||
@@ -666,6 +926,10 @@ switch (cmd) {
|
||||
await cmdHash(rest);
|
||||
break;
|
||||
|
||||
case "render":
|
||||
await cmdRender(rest);
|
||||
break;
|
||||
|
||||
case "cat":
|
||||
await cmdCat(rest);
|
||||
break;
|
||||
@@ -673,15 +937,12 @@ switch (cmd) {
|
||||
case "var": {
|
||||
const [sub, ...subRest] = rest;
|
||||
switch (sub) {
|
||||
case "create":
|
||||
await cmdVarCreate(subRest);
|
||||
case "set":
|
||||
await cmdVarSet(subRest);
|
||||
break;
|
||||
case "get":
|
||||
await cmdVarGet(subRest);
|
||||
break;
|
||||
case "update":
|
||||
await cmdVarUpdate(subRest);
|
||||
break;
|
||||
case "delete":
|
||||
await cmdVarDelete(subRest);
|
||||
break;
|
||||
@@ -697,6 +958,27 @@ switch (cmd) {
|
||||
break;
|
||||
}
|
||||
|
||||
case "template": {
|
||||
const [sub, ...subRest] = rest;
|
||||
switch (sub) {
|
||||
case "set":
|
||||
await cmdTemplateSet(subRest);
|
||||
break;
|
||||
case "get":
|
||||
await cmdTemplateGet(subRest);
|
||||
break;
|
||||
case "list":
|
||||
await cmdTemplateList(subRest);
|
||||
break;
|
||||
case "delete":
|
||||
await cmdTemplateDelete(subRest);
|
||||
break;
|
||||
default:
|
||||
die(`Unknown template subcommand: ${sub ?? "(none)"}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "gc":
|
||||
await cmdGc(rest);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
// ---- Test helpers ----
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let varDbPath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
testDir = join(
|
||||
tmpdir(),
|
||||
`json-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
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 runCli(...args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--store",
|
||||
storePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
...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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bootstrap @string type hash
|
||||
*/
|
||||
async function getStringHash(store: Store): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
return builtinSchemas["@string"] ?? "";
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
describe("template set", () => {
|
||||
test("set template from file", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "Hello {{name}}!");
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
templateFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
expect(output.schemaHash).toBe(stringHash);
|
||||
});
|
||||
|
||||
test("set template with --inline flag", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
"Inline template content",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
expect(output.schemaHash).toBe(stringHash);
|
||||
});
|
||||
|
||||
test("update existing template (idempotent)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "Version 1");
|
||||
|
||||
// Set first time
|
||||
await runCli("template", "set", stringHash, templateFile);
|
||||
|
||||
// Update with new content
|
||||
writeFileSync(templateFile, "Version 2");
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
templateFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
|
||||
// Verify we can get the new version
|
||||
const { stdout: getOut } = await runCli("template", "get", stringHash);
|
||||
expect(getOut).toBe("Version 2");
|
||||
});
|
||||
|
||||
test("error when file not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"/nonexistent/file.txt",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
});
|
||||
|
||||
test("error when schema hash invalid", async () => {
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "content");
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
"INVALID_HASH",
|
||||
templateFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
});
|
||||
|
||||
test("error when both file and --inline provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "content");
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
templateFile,
|
||||
"--inline",
|
||||
"inline content",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
});
|
||||
|
||||
test("support multi-line templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const multilineContent = "Line 1\nLine 2\nLine 3";
|
||||
const { exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
multilineContent,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify content
|
||||
const { stdout: getOut } = await runCli("template", "get", stringHash);
|
||||
expect(getOut).toBe(multilineContent);
|
||||
});
|
||||
|
||||
test("support empty templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
});
|
||||
|
||||
test("error when neither file nor --inline provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "set", stringHash);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("support templates with special characters", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const specialContent = "Template with {{var}} and $env and @ref";
|
||||
const { exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
specialContent,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify content preserved
|
||||
const { stdout: getOut } = await runCli("template", "get", stringHash);
|
||||
expect(getOut).toBe(specialContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template get", () => {
|
||||
test("retrieve template as raw text", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Hello {{name}}!";
|
||||
await runCli("template", "set", stringHash, "--inline", content);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"get",
|
||||
stringHash,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout).toBe(content);
|
||||
});
|
||||
|
||||
test("error when template not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "get", stringHash);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
expect(stderr).toContain("not found");
|
||||
});
|
||||
|
||||
test("preserve exact whitespace", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Note: runCli helper trims stdout, so we test with content that doesn't have leading/trailing whitespace
|
||||
// The actual CLI preserves whitespace correctly
|
||||
const content = "spaces\n\ttabs\t\nmixed";
|
||||
await runCli("template", "set", stringHash, "--inline", content);
|
||||
|
||||
const { stdout } = await runCli("template", "get", stringHash);
|
||||
|
||||
expect(stdout).toBe(content);
|
||||
});
|
||||
|
||||
test("support multi-line templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Note: runCli helper trims stdout, so trailing newline will be removed
|
||||
const multiline = "Line 1\nLine 2\nLine 3";
|
||||
await runCli("template", "set", stringHash, "--inline", multiline);
|
||||
|
||||
const { stdout } = await runCli("template", "get", stringHash);
|
||||
|
||||
expect(stdout).toBe(multiline);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template list", () => {
|
||||
test("list all templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create multiple templates
|
||||
await runCli("template", "set", stringHash, "--inline", "Template 1");
|
||||
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
|
||||
|
||||
const { stdout, exitCode } = await runCli("template", "list");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(Array.isArray(output)).toBe(true);
|
||||
expect(output.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Check structure
|
||||
const item = output[0];
|
||||
expect(item).toHaveProperty("schemaHash");
|
||||
expect(item).toHaveProperty("preview");
|
||||
});
|
||||
|
||||
test("preview truncation for long content", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const longContent = "a".repeat(200);
|
||||
await runCli("template", "set", stringHash, "--inline", longContent);
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
const output = JSON.parse(stdout) as Array<{
|
||||
schemaHash: string;
|
||||
preview: string;
|
||||
}>;
|
||||
const item = output.find((i) => i.schemaHash === stringHash);
|
||||
expect(item).toBeDefined();
|
||||
if (item) {
|
||||
expect(item.preview.length).toBeLessThan(longContent.length);
|
||||
expect(item.preview).toContain("...");
|
||||
}
|
||||
});
|
||||
|
||||
test("empty list when no templates", async () => {
|
||||
const { stdout, exitCode } = await runCli("template", "list");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(Array.isArray(output)).toBe(true);
|
||||
expect(output.length).toBe(0);
|
||||
});
|
||||
|
||||
test("exclude non-template variables", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create a template
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
// Create a regular variable (not under @ucas/template/text/)
|
||||
const hash = await store.put(stringHash, "regular var content");
|
||||
await runCli("var", "set", "regular/var", hash);
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
// Should only contain template variables
|
||||
for (const item of output) {
|
||||
expect(item.schemaHash).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("output JSON array format", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Test");
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
// Should be valid JSON
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(Array.isArray(output)).toBe(true);
|
||||
});
|
||||
|
||||
test("preview shows beginning of content", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Start of template...";
|
||||
await runCli("template", "set", stringHash, "--inline", content);
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
const output = JSON.parse(stdout) as Array<{
|
||||
schemaHash: string;
|
||||
preview: string;
|
||||
}>;
|
||||
const item = output.find((i) => i.schemaHash === stringHash);
|
||||
if (item) {
|
||||
expect(item.preview).toContain("Start");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("template delete", () => {
|
||||
test("delete template variable binding", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("deleted");
|
||||
expect(output.deleted).toBe(true);
|
||||
|
||||
// Verify template is gone
|
||||
const { exitCode: getExitCode } = await runCli(
|
||||
"template",
|
||||
"get",
|
||||
stringHash,
|
||||
);
|
||||
expect(getExitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("error when template not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "delete", stringHash);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
expect(stderr).toContain("not found");
|
||||
});
|
||||
|
||||
test("deletion does not affect other templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create two templates
|
||||
await runCli("template", "set", stringHash, "--inline", "Template 1");
|
||||
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
|
||||
|
||||
// Delete first template
|
||||
await runCli("template", "delete", stringHash);
|
||||
|
||||
// Verify second still exists
|
||||
const { stdout } = await runCli("template", "list");
|
||||
const output = JSON.parse(stdout) as Array<{
|
||||
schemaHash: string;
|
||||
preview: string;
|
||||
}>;
|
||||
|
||||
// Should not find deleted template
|
||||
const deleted = output.find((i) => i.schemaHash === stringHash);
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
test("CAS content remains after variable deletion", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Content");
|
||||
|
||||
// Get the content hash before deletion
|
||||
const { stdout: setOut } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
"Content",
|
||||
);
|
||||
const { contentHash } = JSON.parse(setOut);
|
||||
|
||||
// Delete the template variable
|
||||
await runCli("template", "delete", stringHash);
|
||||
|
||||
// Verify CAS node still exists
|
||||
const { exitCode: hasExitCode } = await runCli("has", contentHash);
|
||||
expect(hasExitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("deletion is non-idempotent (second delete fails)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
// First deletion succeeds
|
||||
const { exitCode: firstExit } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
expect(firstExit).toBe(0);
|
||||
|
||||
// Second deletion fails
|
||||
const { exitCode: secondExit } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
expect(secondExit).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template integration", () => {
|
||||
test("end-to-end workflow: set→get→list→delete", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Integration test template";
|
||||
|
||||
// Set
|
||||
const { exitCode: setExit } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
content,
|
||||
);
|
||||
expect(setExit).toBe(0);
|
||||
|
||||
// Get
|
||||
const { stdout: getOut, exitCode: getExit } = await runCli(
|
||||
"template",
|
||||
"get",
|
||||
stringHash,
|
||||
);
|
||||
expect(getExit).toBe(0);
|
||||
expect(getOut).toBe(content);
|
||||
|
||||
// List
|
||||
const { stdout: listOut, exitCode: listExit } = await runCli(
|
||||
"template",
|
||||
"list",
|
||||
);
|
||||
expect(listExit).toBe(0);
|
||||
const listData = JSON.parse(listOut);
|
||||
expect(listData.length).toBeGreaterThan(0);
|
||||
|
||||
// Delete
|
||||
const { exitCode: delExit } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
expect(delExit).toBe(0);
|
||||
|
||||
// Verify deleted
|
||||
const { exitCode: finalGet } = await runCli("template", "get", stringHash);
|
||||
expect(finalGet).toBe(1);
|
||||
});
|
||||
|
||||
test("templates compatible with generic var commands", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Set via template command
|
||||
await runCli("template", "set", stringHash, "--inline", "Content");
|
||||
|
||||
// List via var command - should see template variable
|
||||
const { stdout } = await runCli("var", "list", "@ucas/template/text/");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("multiple templates for different schemas", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create templates for different schemas
|
||||
await runCli("template", "set", stringHash, "--inline", "Template 1");
|
||||
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
|
||||
await runCli("template", "set", "SCHEMA_HASH_3", "--inline", "Template 3");
|
||||
|
||||
// List should show all
|
||||
const { stdout } = await runCli("template", "list");
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template error handling", () => {
|
||||
test("unknown template subcommand", async () => {
|
||||
const { stderr, exitCode } = await runCli("template", "unknown");
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Unknown");
|
||||
});
|
||||
|
||||
test("missing schema hash argument", async () => {
|
||||
const { stderr, exitCode } = await runCli("template", "set");
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Usage:");
|
||||
});
|
||||
});
|
||||
+1016
-800
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
"liquidjs": "^10.27.0",
|
||||
"xxhash-wasm": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+108
-380
@@ -1,451 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { gc } from "./gc.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Store } from "./types.js";
|
||||
import { createVariableStore, type VariableStore } from "./variable-store.js";
|
||||
import { VariableStore } from "./variable-store.js";
|
||||
|
||||
function tmpDbPath(): string {
|
||||
return `/tmp/test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`;
|
||||
}
|
||||
const tmpDbPath = () =>
|
||||
join(
|
||||
tmpdir(),
|
||||
`test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
|
||||
);
|
||||
|
||||
describe("gc()", () => {
|
||||
describe("GC - Variable Model Refactoring", () => {
|
||||
let store: Store;
|
||||
let varStore: VariableStore;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMemoryStore();
|
||||
dbPath = tmpDbPath();
|
||||
varStore = createVariableStore(dbPath, store);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
varStore.close();
|
||||
try {
|
||||
unlinkSync(dbPath);
|
||||
} catch {
|
||||
// ignore
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves variable-referenced nodes", async () => {
|
||||
// Bootstrap and create schema
|
||||
const _metaHash = await bootstrap(store);
|
||||
test("GC preserves variable-referenced nodes", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
// Put two nodes
|
||||
const hashRef = await store.put(schemaHash, { name: "referenced" });
|
||||
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
|
||||
|
||||
// Create variable pointing to hashRef
|
||||
varStore.create("test/", hashRef);
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
varStore.set("config", hashRef);
|
||||
|
||||
// Run GC
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
// Verify: hashRef exists, hashOrphan removed
|
||||
expect(store.has(hashRef)).toBe(true);
|
||||
expect(store.get(hashRef)).not.toBe(null);
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBe(1);
|
||||
expect(stats.collected).toBeGreaterThanOrEqual(1);
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("removes orphaned nodes", async () => {
|
||||
// Bootstrap and create schema
|
||||
const _metaHash = await bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
test("GC preserves nodes from variables with same name, different schemas", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaA = { type: "object", properties: { x: { type: "number" } } };
|
||||
const schemaB = { type: "object", properties: { y: { type: "string" } } };
|
||||
const schemaAHash = await putSchema(store, schemaA);
|
||||
const schemaBHash = await putSchema(store, schemaB);
|
||||
|
||||
// Put two nodes
|
||||
const hashRef = await store.put(schemaHash, { name: "referenced" });
|
||||
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
|
||||
const hashA = await store.put(schemaAHash, { x: 42 });
|
||||
const hashB = await store.put(schemaBHash, { y: "hello" });
|
||||
const hashOrphan = await store.put(schemaAHash, { x: 99 });
|
||||
|
||||
// Create variable pointing to hashRef
|
||||
varStore.create("test/", hashRef);
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// Run GC
|
||||
gc(store, varStore);
|
||||
varStore.set("config", hashA);
|
||||
varStore.set("config", hashB);
|
||||
|
||||
// Verify: orphan removed
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
});
|
||||
|
||||
test("removes nodes after variable deletion", async () => {
|
||||
// Bootstrap and create schema
|
||||
const _metaHash = await bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
// Put node
|
||||
const hashRef = await store.put(schemaHash, { name: "referenced" });
|
||||
|
||||
// Create variable
|
||||
const variable = varStore.create("test/", hashRef);
|
||||
|
||||
// Delete variable
|
||||
varStore.delete(variable.id);
|
||||
|
||||
// Run GC
|
||||
gc(store, varStore);
|
||||
|
||||
// Verify: node removed
|
||||
expect(store.has(hashRef)).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves schema nodes of reachable nodes", async () => {
|
||||
// Bootstrap and create schema
|
||||
const _metaHash = await bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
// Put node
|
||||
const hashData = await store.put(schemaHash, { name: "data" });
|
||||
|
||||
// Create variable
|
||||
varStore.create("test/", hashData);
|
||||
|
||||
// Run GC
|
||||
gc(store, varStore);
|
||||
|
||||
// Verify: schema preserved
|
||||
expect(store.has(schemaHash)).toBe(true);
|
||||
expect(store.get(schemaHash)).not.toBe(null);
|
||||
});
|
||||
|
||||
test("collects unused schemas", async () => {
|
||||
// Bootstrap
|
||||
const _metaHash = await bootstrap(store);
|
||||
|
||||
// Create two schemas
|
||||
const schemaUsed = {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
};
|
||||
const schemaOrphan = {
|
||||
type: "object",
|
||||
properties: { age: { type: "number" } },
|
||||
};
|
||||
|
||||
const schemaUsedHash = await putSchema(store, schemaUsed);
|
||||
const schemaOrphanHash = await putSchema(store, schemaOrphan);
|
||||
|
||||
// Put node using schemaUsed
|
||||
const hashData = await store.put(schemaUsedHash, { name: "data" });
|
||||
|
||||
// Create variable
|
||||
varStore.create("test/", hashData);
|
||||
|
||||
// Run GC
|
||||
gc(store, varStore);
|
||||
|
||||
// Verify: schemaUsed preserved, schemaOrphan collected
|
||||
expect(store.has(schemaUsedHash)).toBe(true);
|
||||
expect(store.has(schemaOrphanHash)).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves bootstrap meta-schema", async () => {
|
||||
// Bootstrap
|
||||
const metaHash = await bootstrap(store);
|
||||
|
||||
// Create other schemas and nodes (not referencing meta directly)
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
const hashData = await store.put(schemaHash, { name: "data" });
|
||||
|
||||
// Create variable
|
||||
varStore.create("test/", hashData);
|
||||
|
||||
// Run GC
|
||||
gc(store, varStore);
|
||||
|
||||
// Verify: meta-schema preserved
|
||||
expect(store.has(metaHash)).toBe(true);
|
||||
});
|
||||
|
||||
test("handles multiple variables with shared references", async () => {
|
||||
// Bootstrap and create schema
|
||||
const _metaHash = await bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
// Put shared node
|
||||
const hashShared = await store.put(schemaHash, { name: "shared" });
|
||||
|
||||
// Create two variables
|
||||
varStore.create("test/", hashShared);
|
||||
varStore.create("test/", hashShared);
|
||||
|
||||
// Run GC
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
// Verify: node preserved, scanned: 2
|
||||
expect(store.has(hashShared)).toBe(true);
|
||||
expect(store.has(hashA)).toBe(true);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBe(2);
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("deleting one variable doesn't remove shared node", async () => {
|
||||
// Bootstrap and create schema
|
||||
const _metaHash = await bootstrap(store);
|
||||
test("GC removes nodes after variable deletion", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
// Put shared node
|
||||
const hashShared = await store.put(schemaHash, { name: "shared" });
|
||||
const hashRef = await store.put(schemaHash, { name: "referenced" });
|
||||
|
||||
// Create two variables
|
||||
const var1 = varStore.create("test/", hashShared);
|
||||
const _var2 = varStore.create("test/", hashShared);
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// Delete one variable
|
||||
varStore.delete(var1.id);
|
||||
varStore.set("config", hashRef);
|
||||
varStore.remove("config", schemaHash);
|
||||
|
||||
// Run GC
|
||||
gc(store, varStore);
|
||||
|
||||
// Verify: node still preserved
|
||||
expect(store.has(hashShared)).toBe(true);
|
||||
});
|
||||
|
||||
test("deleting all variables removes shared node", async () => {
|
||||
// Bootstrap and create schema
|
||||
const _metaHash = await bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
// Put shared node
|
||||
const hashShared = await store.put(schemaHash, { name: "shared" });
|
||||
|
||||
// Create two variables
|
||||
const var1 = varStore.create("test/", hashShared);
|
||||
const var2 = varStore.create("test/", hashShared);
|
||||
|
||||
// Delete both variables
|
||||
varStore.delete(var1.id);
|
||||
varStore.delete(var2.id);
|
||||
|
||||
// Run GC
|
||||
gc(store, varStore);
|
||||
|
||||
// Verify: node removed
|
||||
expect(store.has(hashShared)).toBe(false);
|
||||
});
|
||||
|
||||
test("walks deep reference chains", async () => {
|
||||
// Bootstrap
|
||||
const _metaHash = await bootstrap(store);
|
||||
|
||||
// Create schema with cas_ref field and a name field to differentiate nodes
|
||||
const schemaTree = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
child: {
|
||||
anyOf: [{ type: "null" }, { type: "string", format: "cas_ref" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const schemaTreeHash = await putSchema(store, schemaTree);
|
||||
|
||||
// Create chain: A -> B -> C
|
||||
const hashC = await store.put(schemaTreeHash, { name: "C", child: null });
|
||||
const hashB = await store.put(schemaTreeHash, {
|
||||
name: "B",
|
||||
child: hashC,
|
||||
});
|
||||
const hashA = await store.put(schemaTreeHash, {
|
||||
name: "A",
|
||||
child: hashB,
|
||||
});
|
||||
|
||||
// Create orphan (different content so it gets a different hash)
|
||||
const hashOrphan = await store.put(schemaTreeHash, {
|
||||
name: "orphan",
|
||||
child: null,
|
||||
});
|
||||
|
||||
// Create variable pointing to A
|
||||
varStore.create("test/", hashA);
|
||||
|
||||
// Run GC
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
// Verify: A, B, C preserved; orphan removed
|
||||
expect(store.has(hashA)).toBe(true);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(store.has(hashC)).toBe(true);
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
expect(stats.reachable).toBeGreaterThanOrEqual(4); // A, B, C, schemaTree
|
||||
});
|
||||
|
||||
test("handles cycles without hanging", async () => {
|
||||
// Bootstrap
|
||||
const _metaHash = await bootstrap(store);
|
||||
|
||||
// Create schema with cas_ref field
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
child: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
};
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
// We need to create a cycle: X -> Y -> X
|
||||
// This requires getting the hash before putting
|
||||
// For simplicity, we'll create a self-referencing node
|
||||
const hashX = await store.put(schemaHash, { child: "placeholder" });
|
||||
|
||||
// Now manually update the node to reference itself (this is a workaround)
|
||||
// In reality, we can't easily create cycles without modifying the store
|
||||
// But the walk function should handle it gracefully
|
||||
|
||||
// Create variable
|
||||
varStore.create("test/", hashX);
|
||||
|
||||
// Run GC - should not hang
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
// Verify: completes without hanging
|
||||
expect(store.has(hashX)).toBe(true);
|
||||
expect(stats.scanned).toBe(1);
|
||||
});
|
||||
|
||||
test("handles empty variable store", async () => {
|
||||
// Bootstrap
|
||||
const metaHash = await bootstrap(store);
|
||||
|
||||
// Create some schemas and nodes
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
const hash1 = await store.put(schemaHash, { name: "node1" });
|
||||
const hash2 = await store.put(schemaHash, { name: "node2" });
|
||||
|
||||
// NO variables created
|
||||
|
||||
// Run GC
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
// Verify: all user nodes removed, scanned: 0
|
||||
expect(store.has(hashRef)).toBe(false);
|
||||
expect(stats.scanned).toBe(0);
|
||||
expect(stats.collected).toBeGreaterThan(0);
|
||||
expect(store.has(hash1)).toBe(false);
|
||||
expect(store.has(hash2)).toBe(false);
|
||||
// Bootstrap meta-schema should still exist
|
||||
expect(store.has(metaHash)).toBe(true);
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("handles empty CAS store", () => {
|
||||
// Fresh store, no bootstrap, no nodes
|
||||
test("GC is global across all variables", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaA = { type: "object", properties: { x: { type: "number" } } };
|
||||
const schemaB = { type: "object", properties: { y: { type: "string" } } };
|
||||
const schemaAHash = await putSchema(store, schemaA);
|
||||
const schemaBHash = await putSchema(store, schemaB);
|
||||
|
||||
const hash1 = await store.put(schemaAHash, { x: 1 });
|
||||
const hash2 = await store.put(schemaAHash, { x: 2 });
|
||||
const hash3 = await store.put(schemaBHash, { y: "a" });
|
||||
const hashOrphan = await store.put(schemaAHash, { x: 999 });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
varStore.set("uwf.thread", hash1);
|
||||
varStore.set("uwf.workflow", hash2);
|
||||
varStore.set("app.config", hash3);
|
||||
|
||||
// Run GC
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
// Verify: completes without error
|
||||
expect(stats.total).toBe(0);
|
||||
expect(stats.reachable).toBe(0);
|
||||
expect(stats.collected).toBe(0);
|
||||
expect(stats.scanned).toBe(0);
|
||||
});
|
||||
|
||||
test("is global across all scopes", async () => {
|
||||
// Bootstrap
|
||||
const _metaHash = await bootstrap(store);
|
||||
|
||||
// Create schema
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
// Create variables in different scopes
|
||||
const hashA = await store.put(schemaHash, { name: "A" });
|
||||
const hashB = await store.put(schemaHash, { name: "B" });
|
||||
const hashC = await store.put(schemaHash, { name: "C" });
|
||||
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
|
||||
|
||||
varStore.create("uwf/thread/", hashA);
|
||||
varStore.create("uwf/workflow/", hashB);
|
||||
varStore.create("app/config/", hashC);
|
||||
|
||||
// Run GC
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
// Verify: all three preserved, orphan removed
|
||||
expect(store.has(hashA)).toBe(true);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(store.has(hashC)).toBe(true);
|
||||
expect(store.has(hash1)).toBe(true);
|
||||
expect(store.has(hash2)).toBe(true);
|
||||
expect(store.has(hash3)).toBe(true);
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBe(3);
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("returns accurate stats", async () => {
|
||||
// Bootstrap
|
||||
const _metaHash = await bootstrap(store);
|
||||
test("GC integration with refactored variable store", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Create schemas and nodes
|
||||
const schema1 = {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
};
|
||||
const schema2 = {
|
||||
type: "object",
|
||||
properties: { age: { type: "number" } },
|
||||
};
|
||||
const schemaA = { type: "object", properties: { x: { type: "number" } } };
|
||||
const schemaB = { type: "object", properties: { y: { type: "string" } } };
|
||||
const schemaAHash = await putSchema(store, schemaA);
|
||||
const schemaBHash = await putSchema(store, schemaB);
|
||||
|
||||
const schema1Hash = await putSchema(store, schema1);
|
||||
const schema2Hash = await putSchema(store, schema2);
|
||||
const hashA1 = await store.put(schemaAHash, { x: 1 });
|
||||
const hashA2 = await store.put(schemaAHash, { x: 2 });
|
||||
const hashB = await store.put(schemaBHash, { y: "hello" });
|
||||
const hashOrphan1 = await store.put(schemaAHash, { x: 999 });
|
||||
const hashOrphan2 = await store.put(schemaBHash, { y: "orphan" });
|
||||
|
||||
// Create 2 nodes
|
||||
const hash1 = await store.put(schema1Hash, { name: "node1" });
|
||||
const hash2 = await store.put(schema2Hash, { age: 42 });
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// Create 3 orphans
|
||||
const _orphan1 = await store.put(schema1Hash, { name: "orphan1" });
|
||||
const _orphan2 = await store.put(schema1Hash, { name: "orphan2" });
|
||||
const _orphan3 = await store.put(schema2Hash, { age: 99 });
|
||||
// Create variables
|
||||
varStore.set("var1", hashA1);
|
||||
varStore.set("var2", hashA2);
|
||||
varStore.set("var3", hashB);
|
||||
|
||||
// Create 2 variables
|
||||
varStore.create("test/", hash1);
|
||||
varStore.create("test/", hash2);
|
||||
// First GC: orphans removed
|
||||
let stats = gc(store, varStore);
|
||||
expect(store.has(hashA1)).toBe(true);
|
||||
expect(store.has(hashA2)).toBe(true);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(store.has(hashOrphan1)).toBe(false);
|
||||
expect(store.has(hashOrphan2)).toBe(false);
|
||||
expect(stats.scanned).toBe(3);
|
||||
|
||||
// Count total before GC
|
||||
const totalBefore = 8; // metaHash, schema1Hash, schema2Hash, hash1, hash2, orphan1, orphan2, orphan3
|
||||
// Delete one variable
|
||||
varStore.remove("var2", schemaAHash);
|
||||
|
||||
// Run GC
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
// Verify stats
|
||||
expect(stats.total).toBe(totalBefore);
|
||||
// Second GC: hashA2 removed
|
||||
stats = gc(store, varStore);
|
||||
expect(store.has(hashA1)).toBe(true);
|
||||
expect(store.has(hashA2)).toBe(false);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(stats.scanned).toBe(2);
|
||||
expect(stats.reachable).toBe(5); // metaHash, schema1Hash, schema2Hash, hash1, hash2
|
||||
expect(stats.collected).toBe(3); // orphan1, orphan2, orphan3
|
||||
});
|
||||
|
||||
test("handles missing CAS nodes gracefully", async () => {
|
||||
// Bootstrap
|
||||
const _metaHash = await bootstrap(store);
|
||||
|
||||
// Create schema
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
// Create a valid node
|
||||
const hashValid = await store.put(schemaHash, { name: "valid" });
|
||||
|
||||
// Create variable pointing to valid node
|
||||
varStore.create("test/", hashValid);
|
||||
|
||||
// Manually create a variable with non-existent hash (simulate corruption)
|
||||
// We'll use the variable store's internal DB to insert a fake variable
|
||||
// For simplicity, we'll skip this test as it requires internal access
|
||||
|
||||
// Run GC
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
// Verify: completes without crashing
|
||||
expect(stats.scanned).toBeGreaterThanOrEqual(1);
|
||||
varStore.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,8 @@ 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 { renderWithTemplate } from "./liquid-render.js";
|
||||
export { type RenderOptions, render, renderAsync } from "./render.js";
|
||||
export type { JSONSchema } from "./schema.js";
|
||||
export {
|
||||
getSchema,
|
||||
@@ -15,12 +17,12 @@ export {
|
||||
} from "./schema.js";
|
||||
export { createMemoryStore } from "./store.js";
|
||||
export type { CasNode, Hash, Store } from "./types.js";
|
||||
export type { Variable, VariableId } from "./variable.js";
|
||||
export type { Variable } from "./variable.js";
|
||||
export {
|
||||
CasNodeNotFoundError,
|
||||
createVariableStore,
|
||||
InvalidScopeError,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
import { type Context, Liquid, type TagToken } from "liquidjs";
|
||||
import { putSchema } 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
|
||||
};
|
||||
|
||||
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,
|
||||
decay,
|
||||
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,
|
||||
globalDecay,
|
||||
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,
|
||||
_globalDecay: 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";
|
||||
}
|
||||
@@ -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,294 @@
|
||||
import { renderWithTemplate } from "./liquid-render.js";
|
||||
import { 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;
|
||||
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.
|
||||
* 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 = 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = options?.resolution ?? DEFAULT_RESOLUTION;
|
||||
const decay = options?.decay ?? DEFAULT_DECAY;
|
||||
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
|
||||
const varStore = options?.varStore;
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,30 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { ulid } from "ulidx";
|
||||
import type { Store } from "./types.js";
|
||||
import type { Variable, VariableId } from "./variable.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
/**
|
||||
* Custom error types for variable operations
|
||||
*/
|
||||
export class VariableNotFoundError extends Error {
|
||||
constructor(id: VariableId) {
|
||||
super(`Variable not found: ${id}`);
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public variableSchema: Hash,
|
||||
) {
|
||||
super(`Variable not found: name=${variableName}, schema=${variableSchema}`);
|
||||
this.name = "VariableNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidVariableNameError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public reason: string,
|
||||
) {
|
||||
super(`Invalid variable name "${variableName}": ${reason}`);
|
||||
this.name = "InvalidVariableNameError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SchemaMismatchError extends Error {
|
||||
constructor(
|
||||
public expected: string,
|
||||
@@ -23,13 +35,6 @@ export class SchemaMismatchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidScopeError extends Error {
|
||||
constructor(scope: string) {
|
||||
super(`Invalid scope: scope must end with / (got: ${scope})`);
|
||||
this.name = "InvalidScopeError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CasNodeNotFoundError extends Error {
|
||||
constructor(hash: string) {
|
||||
super(`CAS node not found: ${hash}`);
|
||||
@@ -66,37 +71,41 @@ export class VariableStore {
|
||||
private casStore: Store,
|
||||
) {
|
||||
this.db = new Database(dbPath, { create: true });
|
||||
// Enable foreign keys
|
||||
this.db.exec("PRAGMA foreign_keys = ON");
|
||||
this.initDb();
|
||||
}
|
||||
|
||||
private initDb(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS variables (
|
||||
id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
schema TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created INTEGER NOT NULL,
|
||||
updated INTEGER NOT NULL
|
||||
updated INTEGER NOT NULL,
|
||||
PRIMARY KEY (name, schema)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_var_scope ON variables(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_name ON variables(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variable_tags (
|
||||
variable_id TEXT NOT NULL,
|
||||
variable_name TEXT NOT NULL,
|
||||
variable_schema TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (variable_id, key),
|
||||
FOREIGN KEY (variable_id) REFERENCES variables(id) ON DELETE CASCADE
|
||||
PRIMARY KEY (variable_name, variable_schema, key),
|
||||
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variable_labels (
|
||||
variable_id TEXT NOT NULL,
|
||||
variable_name TEXT NOT NULL,
|
||||
variable_schema TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (variable_id, name),
|
||||
FOREIGN KEY (variable_id) REFERENCES variables(id) ON DELETE CASCADE
|
||||
PRIMARY KEY (variable_name, variable_schema, name),
|
||||
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key);
|
||||
@@ -106,11 +115,51 @@ export class VariableStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that scope ends with /
|
||||
* Validate variable name format
|
||||
* @ is allowed at the start of the first segment (system-reserved)
|
||||
*/
|
||||
private validateScope(scope: string): void {
|
||||
if (!scope.endsWith("/")) {
|
||||
throw new InvalidScopeError(scope);
|
||||
private validateName(name: string): void {
|
||||
// Rule 1: Cannot be empty
|
||||
if (name === "") {
|
||||
throw new InvalidVariableNameError(name, "Name cannot be empty");
|
||||
}
|
||||
|
||||
// Rule 2: No leading slash
|
||||
if (name.startsWith("/")) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot start with leading slash",
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 3: No trailing slash
|
||||
if (name.endsWith("/")) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot end with trailing slash",
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment)
|
||||
const segments = name.split("/");
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i] as string;
|
||||
if (segment === "") {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name contains empty segment (consecutive slashes //)",
|
||||
);
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
// 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 ${i === 0 ? "@, " : ""}a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,19 +175,146 @@ export class VariableStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new variable
|
||||
* Load tags for a variable
|
||||
*/
|
||||
create(
|
||||
scope: string,
|
||||
private loadTags(name: string, schema: Hash): Record<string, string> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT key, value
|
||||
FROM variable_tags
|
||||
WHERE variable_name = ? AND variable_schema = ?
|
||||
`);
|
||||
|
||||
const rows = stmt.all(name, schema) as Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
}>;
|
||||
const tags: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
tags[row.key] = row.value;
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load labels for a variable
|
||||
*/
|
||||
private loadLabels(name: string, schema: Hash): string[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT name
|
||||
FROM variable_labels
|
||||
WHERE variable_name = ? AND variable_schema = ?
|
||||
ORDER BY name ASC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(name, schema) as Array<{ name: string }>;
|
||||
return rows.map((row) => row.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a variable (upsert: create or update)
|
||||
*/
|
||||
set(
|
||||
name: string,
|
||||
value: string,
|
||||
options?: {
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
},
|
||||
): Variable {
|
||||
this.validateScope(scope);
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const schema = this.extractSchema(value);
|
||||
|
||||
// Check if variable exists
|
||||
const existing = this.get(name, schema);
|
||||
|
||||
if (existing !== null) {
|
||||
// Update existing variable
|
||||
const now = Date.now();
|
||||
|
||||
// If options provided, use them; otherwise preserve existing
|
||||
const tags = options?.tags ?? existing.tags;
|
||||
const labels = options?.labels ?? existing.labels;
|
||||
|
||||
// Check for tag/label conflicts when updating with new options
|
||||
if (options !== undefined) {
|
||||
const tagKeys = Object.keys(tags);
|
||||
for (const key of tagKeys) {
|
||||
if (labels.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
try {
|
||||
// Update value and timestamp
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE variables
|
||||
SET value = ?, updated = ?
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
updateStmt.run(value, now, name, schema);
|
||||
|
||||
// If options provided, update tags/labels
|
||||
if (options !== undefined) {
|
||||
// Delete existing tags and labels
|
||||
this.db
|
||||
.prepare(`
|
||||
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ?
|
||||
`)
|
||||
.run(name, schema);
|
||||
|
||||
this.db
|
||||
.prepare(`
|
||||
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ?
|
||||
`)
|
||||
.run(name, schema);
|
||||
|
||||
// Insert new tags
|
||||
const tagKeys = Object.keys(tags);
|
||||
if (tagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, val] of Object.entries(tags)) {
|
||||
tagStmt.run(name, schema, key, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new labels
|
||||
if (labels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const labelName of labels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
created: existing.created,
|
||||
updated: now,
|
||||
tags,
|
||||
labels: [...labels],
|
||||
};
|
||||
}
|
||||
|
||||
// Create new variable
|
||||
const tags = options?.tags ?? {};
|
||||
const labels = options?.labels ?? [];
|
||||
|
||||
@@ -150,38 +326,37 @@ export class VariableStore {
|
||||
}
|
||||
}
|
||||
|
||||
const id = ulid();
|
||||
const now = Date.now();
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO variables (id, scope, value, schema, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO variables (name, schema, value, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(id, scope, value, schema, now, now);
|
||||
stmt.run(name, schema, value, now, now);
|
||||
|
||||
// Insert tags
|
||||
if (tagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT INTO variable_tags (variable_id, key, value)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, val] of Object.entries(tags)) {
|
||||
tagStmt.run(id, key, val);
|
||||
tagStmt.run(name, schema, key, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert labels
|
||||
if (labels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT INTO variable_labels (variable_id, name)
|
||||
VALUES (?, ?)
|
||||
INSERT INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const name of labels) {
|
||||
labelStmt.run(id, name);
|
||||
for (const labelName of labels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +367,9 @@ export class VariableStore {
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
scope,
|
||||
value,
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
created: now,
|
||||
updated: now,
|
||||
tags,
|
||||
@@ -204,54 +378,27 @@ export class VariableStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tags for a variable
|
||||
* Get a variable by name, optionally with schema
|
||||
*/
|
||||
private loadTags(id: VariableId): Record<string, string> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT key, value
|
||||
FROM variable_tags
|
||||
WHERE variable_id = ?
|
||||
`);
|
||||
|
||||
const rows = stmt.all(id) as Array<{ key: string; value: string }>;
|
||||
const tags: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
tags[row.key] = row.value;
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load labels for a variable
|
||||
* Get a variable by name and schema
|
||||
* @param name - Variable name
|
||||
* @param schema - Schema hash (required)
|
||||
* @returns Variable if found, null otherwise
|
||||
*/
|
||||
private loadLabels(id: VariableId): string[] {
|
||||
get(name: string, schema: Hash): Variable | null {
|
||||
// Precise match with schema
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT name
|
||||
FROM variable_labels
|
||||
WHERE variable_id = ?
|
||||
ORDER BY name ASC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(id) as Array<{ name: string }>;
|
||||
return rows.map((row) => row.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a variable by ID
|
||||
*/
|
||||
get(id: VariableId): Variable | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, scope, value, schema, created, updated
|
||||
SELECT name, schema, value, created, updated
|
||||
FROM variables
|
||||
WHERE id = ?
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
const row = stmt.get(id) as
|
||||
const row = stmt.get(name, schema) as
|
||||
| {
|
||||
id: string;
|
||||
scope: string;
|
||||
value: string;
|
||||
name: string;
|
||||
schema: string;
|
||||
value: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}
|
||||
@@ -262,14 +409,13 @@ export class VariableStore {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = this.loadTags(row.id);
|
||||
const labels = this.loadLabels(row.id);
|
||||
const tags = this.loadTags(row.name, row.schema);
|
||||
const labels = this.loadLabels(row.name, row.schema);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
scope: row.scope,
|
||||
value: row.value,
|
||||
name: row.name,
|
||||
schema: row.schema,
|
||||
value: row.value,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
tags,
|
||||
@@ -280,10 +426,13 @@ export class VariableStore {
|
||||
/**
|
||||
* Update a variable's value (with schema validation)
|
||||
*/
|
||||
update(id: VariableId, value: string): Variable {
|
||||
const existing = this.get(id);
|
||||
update(name: string, schema: Hash, value: string): Variable {
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(id);
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const newSchema = this.extractSchema(value);
|
||||
@@ -296,10 +445,10 @@ export class VariableStore {
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE variables
|
||||
SET value = ?, updated = ?
|
||||
WHERE id = ?
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
stmt.run(value, now, id);
|
||||
stmt.run(value, now, name, schema);
|
||||
|
||||
return {
|
||||
...existing,
|
||||
@@ -309,43 +458,69 @@ export class VariableStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a variable
|
||||
* Remove a variable (or all variants if schema omitted)
|
||||
*/
|
||||
delete(id: VariableId): Variable {
|
||||
const existing = this.get(id);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(id);
|
||||
remove(name: string): Variable[];
|
||||
remove(name: string, schema: Hash): Variable;
|
||||
remove(name: string, schema?: Hash): Variable | Variable[] {
|
||||
if (schema !== undefined) {
|
||||
// Remove specific (name, schema) variant
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM variables WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
stmt.run(name, schema);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Remove all schema variants for this name
|
||||
const variants = this.list({ exactName: name });
|
||||
|
||||
if (variants.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM variables WHERE id = ?
|
||||
DELETE FROM variables WHERE name = ?
|
||||
`);
|
||||
|
||||
stmt.run(id);
|
||||
stmt.run(name);
|
||||
|
||||
return existing;
|
||||
return variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* List variables matching a scope prefix
|
||||
* List variables with optional filters
|
||||
*/
|
||||
list(options?: {
|
||||
scope?: string;
|
||||
namePrefix?: string;
|
||||
exactName?: string;
|
||||
schema?: Hash;
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
}): Variable[] {
|
||||
const scope = options?.scope ?? "";
|
||||
// Validate mutually exclusive options
|
||||
if (options?.namePrefix !== undefined && options?.exactName !== undefined) {
|
||||
throw new Error(
|
||||
"namePrefix and exactName are mutually exclusive - cannot specify both",
|
||||
);
|
||||
}
|
||||
|
||||
const namePrefix = options?.namePrefix ?? "";
|
||||
const exactName = options?.exactName;
|
||||
const schema = options?.schema;
|
||||
const filterTags = options?.tags ?? {};
|
||||
const filterLabels = options?.labels ?? [];
|
||||
|
||||
// Validate scope format (must end with / if non-empty)
|
||||
if (scope !== "" && !scope.endsWith("/")) {
|
||||
throw new InvalidScopeError(scope);
|
||||
}
|
||||
|
||||
// Build query with tag/label filtering
|
||||
// Build query with filters
|
||||
let query = `
|
||||
SELECT DISTINCT v.id, v.scope, v.value, v.schema, v.created, v.updated
|
||||
SELECT DISTINCT v.name, v.schema, v.value, v.created, v.updated
|
||||
FROM variables v
|
||||
`;
|
||||
|
||||
@@ -357,7 +532,8 @@ export class VariableStore {
|
||||
const key = tagKeys[i] as string;
|
||||
const value = filterTags[key] as string;
|
||||
query += `
|
||||
INNER JOIN variable_tags t${i} ON v.id = t${i}.variable_id
|
||||
INNER JOIN variable_tags t${i} ON v.name = t${i}.variable_name
|
||||
AND v.schema = t${i}.variable_schema
|
||||
AND t${i}.key = ? AND t${i}.value = ?
|
||||
`;
|
||||
params.push(key, value);
|
||||
@@ -367,36 +543,52 @@ export class VariableStore {
|
||||
for (let i = 0; i < filterLabels.length; i++) {
|
||||
const label = filterLabels[i] as string;
|
||||
query += `
|
||||
INNER JOIN variable_labels l${i} ON v.id = l${i}.variable_id
|
||||
INNER JOIN variable_labels l${i} ON v.name = l${i}.variable_name
|
||||
AND v.schema = l${i}.variable_schema
|
||||
AND l${i}.name = ?
|
||||
`;
|
||||
params.push(label);
|
||||
}
|
||||
|
||||
// Scope filter (always present)
|
||||
query += " WHERE v.scope LIKE ? || '%'";
|
||||
params.push(scope);
|
||||
// WHERE clause for name filters and schema
|
||||
const whereClauses: string[] = [];
|
||||
|
||||
if (exactName !== undefined) {
|
||||
whereClauses.push("v.name = ?");
|
||||
params.push(exactName);
|
||||
} else if (namePrefix !== "") {
|
||||
whereClauses.push("v.name LIKE ? || '%'");
|
||||
params.push(namePrefix);
|
||||
}
|
||||
|
||||
if (schema !== undefined) {
|
||||
whereClauses.push("v.schema = ?");
|
||||
params.push(schema);
|
||||
}
|
||||
|
||||
if (whereClauses.length > 0) {
|
||||
query += ` WHERE ${whereClauses.join(" AND ")}`;
|
||||
}
|
||||
|
||||
query += " ORDER BY v.created ASC";
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...params) as Array<{
|
||||
id: string;
|
||||
scope: string;
|
||||
value: string;
|
||||
name: string;
|
||||
schema: string;
|
||||
value: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
scope: row.scope,
|
||||
value: row.value,
|
||||
name: row.name,
|
||||
schema: row.schema,
|
||||
value: row.value,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
tags: this.loadTags(row.id),
|
||||
labels: this.loadLabels(row.id),
|
||||
tags: this.loadTags(row.name, row.schema),
|
||||
labels: this.loadLabels(row.name, row.schema),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -404,16 +596,20 @@ export class VariableStore {
|
||||
* Add/update/delete tags and labels
|
||||
*/
|
||||
tag(
|
||||
id: VariableId,
|
||||
name: string,
|
||||
schema: Hash,
|
||||
operations: {
|
||||
add?: Record<string, string>; // tags to add/update
|
||||
addLabels?: string[]; // labels to add
|
||||
delete?: string[]; // tag keys or label names to delete
|
||||
},
|
||||
): Variable {
|
||||
const existing = this.get(id);
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(id);
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const addTags = operations.add ?? {};
|
||||
@@ -433,14 +629,17 @@ export class VariableStore {
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of addLabels) {
|
||||
for (const labelName of addLabels) {
|
||||
// Check if this name is being added as a tag in the same operation
|
||||
if (newTagKeys.includes(name)) {
|
||||
throw new TagLabelConflictError(name, "tag", "label");
|
||||
if (newTagKeys.includes(labelName)) {
|
||||
throw new TagLabelConflictError(labelName, "tag", "label");
|
||||
}
|
||||
// Check if this name already exists as a tag key (and not being deleted)
|
||||
if (existing.tags[name] !== undefined && !deleteNames.includes(name)) {
|
||||
throw new TagLabelConflictError(name, "tag", "label");
|
||||
if (
|
||||
existing.tags[labelName] !== undefined &&
|
||||
!deleteNames.includes(labelName)
|
||||
) {
|
||||
throw new TagLabelConflictError(labelName, "tag", "label");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,43 +650,43 @@ export class VariableStore {
|
||||
try {
|
||||
// Update timestamp
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE variables SET updated = ? WHERE id = ?
|
||||
UPDATE variables SET updated = ? WHERE name = ? AND schema = ?
|
||||
`);
|
||||
updateStmt.run(now, id);
|
||||
updateStmt.run(now, name, schema);
|
||||
|
||||
// Delete tags and labels
|
||||
if (deleteNames.length > 0) {
|
||||
const deleteTagStmt = this.db.prepare(`
|
||||
DELETE FROM variable_tags WHERE variable_id = ? AND key = ?
|
||||
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ? AND key = ?
|
||||
`);
|
||||
const deleteLabelStmt = this.db.prepare(`
|
||||
DELETE FROM variable_labels WHERE variable_id = ? AND name = ?
|
||||
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ? AND name = ?
|
||||
`);
|
||||
for (const name of deleteNames) {
|
||||
deleteTagStmt.run(id, name);
|
||||
deleteLabelStmt.run(id, name);
|
||||
for (const deleteName of deleteNames) {
|
||||
deleteTagStmt.run(name, schema, deleteName);
|
||||
deleteLabelStmt.run(name, schema, deleteName);
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update tags
|
||||
if (newTagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO variable_tags (variable_id, key, value)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT OR REPLACE INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, value] of Object.entries(addTags)) {
|
||||
tagStmt.run(id, key, value);
|
||||
tagStmt.run(name, schema, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels (with conflict handling)
|
||||
if (addLabels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO variable_labels (variable_id, name)
|
||||
VALUES (?, ?)
|
||||
INSERT OR IGNORE INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const name of addLabels) {
|
||||
labelStmt.run(id, name);
|
||||
for (const labelName of addLabels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,9 +697,9 @@ export class VariableStore {
|
||||
}
|
||||
|
||||
// Return updated variable
|
||||
const updated = this.get(id);
|
||||
const updated = this.get(name, schema);
|
||||
if (updated === null) {
|
||||
throw new VariableNotFoundError(id);
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1,740 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Store } from "./types.js";
|
||||
import {
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
VariableStore,
|
||||
} from "./variable-store.js";
|
||||
|
||||
describe("VariableStore - Tags and Labels (RFC-20 Phase 2)", () => {
|
||||
let store: Store;
|
||||
let varStore: VariableStore;
|
||||
let dbPath: string;
|
||||
let schemaHash: string;
|
||||
let hashA: string;
|
||||
let hashB: string;
|
||||
let hashC: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = join(tmpdir(), `test-variables-phase2-${Date.now()}.db`);
|
||||
store = createMemoryStore();
|
||||
|
||||
// Create test schema
|
||||
schemaHash = await store.put("BOOTSTRAPHASH", {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
});
|
||||
|
||||
// Create test CAS nodes
|
||||
hashA = await store.put(schemaHash, { name: "a" });
|
||||
hashB = await store.put(schemaHash, { name: "b" });
|
||||
hashC = await store.put(schemaHash, { name: "c" });
|
||||
|
||||
varStore = new VariableStore(dbPath, store);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
varStore.close();
|
||||
try {
|
||||
unlinkSync(dbPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe("Test Group 0: Setup and Backward Compatibility", () => {
|
||||
test("0.1: Create variable without tags/labels", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA);
|
||||
|
||||
expect(variable.tags).toEqual({});
|
||||
expect(variable.labels).toEqual([]);
|
||||
expect(variable.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
|
||||
expect(variable.scope).toBe("uwf/thread/");
|
||||
expect(variable.value).toBe(hashA);
|
||||
});
|
||||
|
||||
test("0.2: Get variable returns empty tags and labels", () => {
|
||||
const created = varStore.create("uwf/thread/", hashA);
|
||||
const retrieved = varStore.get(created.id);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.tags).toEqual({});
|
||||
expect(retrieved?.labels).toEqual([]);
|
||||
});
|
||||
|
||||
test("0.3: Create variable with initial tags", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active", workflow: "solve-issue" },
|
||||
});
|
||||
|
||||
expect(variable.tags).toEqual({
|
||||
status: "active",
|
||||
workflow: "solve-issue",
|
||||
});
|
||||
expect(variable.labels).toEqual([]);
|
||||
});
|
||||
|
||||
test("0.4: Create variable with initial labels", () => {
|
||||
const variable = varStore.create("uwf/workflow/", hashC, {
|
||||
labels: ["pinned"],
|
||||
});
|
||||
|
||||
expect(variable.tags).toEqual({});
|
||||
expect(variable.labels).toEqual(["pinned"]);
|
||||
});
|
||||
|
||||
test("0.5: Create variable with both tags and labels", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active" },
|
||||
labels: ["pinned"],
|
||||
});
|
||||
|
||||
expect(variable.tags).toEqual({ status: "active" });
|
||||
expect(variable.labels).toEqual(["pinned"]);
|
||||
});
|
||||
|
||||
test("0.6: Create variable with conflicting tag/label throws error", () => {
|
||||
expect(() =>
|
||||
varStore.create("uwf/thread/", hashA, {
|
||||
tags: { workflow: "solve-issue" },
|
||||
labels: ["workflow"],
|
||||
}),
|
||||
).toThrow(TagLabelConflictError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 1: Tag Operations", () => {
|
||||
test("1.1: Add tag to existing variable", async () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active" },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
add: { priority: "high" },
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual({
|
||||
status: "active",
|
||||
priority: "high",
|
||||
});
|
||||
expect(updated.updated).toBeGreaterThan(variable.updated);
|
||||
});
|
||||
|
||||
test("1.2: Tag same-key override", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active" },
|
||||
});
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
add: { status: "completed" },
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual({ status: "completed" });
|
||||
expect(Object.keys(updated.tags)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("1.3: Delete tag using delete array", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active", workflow: "solve-issue" },
|
||||
});
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
delete: ["status"],
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual({ workflow: "solve-issue" });
|
||||
expect(updated.tags.status).toBeUndefined();
|
||||
});
|
||||
|
||||
test("1.4: Delete non-existent tag is idempotent", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active" },
|
||||
});
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
delete: ["nonexistent"],
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual({ status: "active" });
|
||||
});
|
||||
|
||||
test("1.5: Multiple tag operations in single call", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active", workflow: "solve-issue" },
|
||||
});
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
add: { env: "production", region: "us-west" },
|
||||
delete: ["workflow"],
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual({
|
||||
status: "active",
|
||||
env: "production",
|
||||
region: "us-west",
|
||||
});
|
||||
});
|
||||
|
||||
test("1.6: Delete then add same key in single operation", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active" },
|
||||
});
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
delete: ["status"],
|
||||
add: { status: "new" },
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual({ status: "new" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 2: Label Operations", () => {
|
||||
test("2.1: Add label to existing variable", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA);
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
addLabels: ["archived"],
|
||||
});
|
||||
|
||||
expect(updated.labels).toContain("archived");
|
||||
expect(updated.labels).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("2.2: Delete label using delete array", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
labels: ["archived", "pinned"],
|
||||
});
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
delete: ["archived"],
|
||||
});
|
||||
|
||||
expect(updated.labels).toEqual(["pinned"]);
|
||||
});
|
||||
|
||||
test("2.3: Add duplicate label is idempotent", () => {
|
||||
const variable = varStore.create("uwf/workflow/", hashC, {
|
||||
labels: ["pinned"],
|
||||
});
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
addLabels: ["pinned"],
|
||||
});
|
||||
|
||||
expect(updated.labels).toEqual(["pinned"]);
|
||||
});
|
||||
|
||||
test("2.4: Multiple label operations in single call", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
labels: ["archived"],
|
||||
});
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
addLabels: ["experimental", "deprecated"],
|
||||
delete: ["archived"],
|
||||
});
|
||||
|
||||
expect(updated.labels).toHaveLength(2);
|
||||
expect(updated.labels).toContain("experimental");
|
||||
expect(updated.labels).toContain("deprecated");
|
||||
expect(updated.labels).not.toContain("archived");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 3: Tag/Label Mutual Exclusion", () => {
|
||||
test("3.1: Label conflicts with existing tag key", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { workflow: "solve-issue" },
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
varStore.tag(variable.id, {
|
||||
addLabels: ["workflow"],
|
||||
}),
|
||||
).toThrow(TagLabelConflictError);
|
||||
|
||||
// Verify variable state unchanged
|
||||
const retrieved = varStore.get(variable.id);
|
||||
expect(retrieved?.tags).toEqual({ workflow: "solve-issue" });
|
||||
expect(retrieved?.labels).toEqual([]);
|
||||
});
|
||||
|
||||
test("3.2: Tag conflicts with existing label", () => {
|
||||
const variable = varStore.create("uwf/workflow/", hashC, {
|
||||
labels: ["pinned"],
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
varStore.tag(variable.id, {
|
||||
add: { pinned: "true" },
|
||||
}),
|
||||
).toThrow(TagLabelConflictError);
|
||||
|
||||
// Verify variable state unchanged
|
||||
const retrieved = varStore.get(variable.id);
|
||||
expect(retrieved?.tags).toEqual({});
|
||||
expect(retrieved?.labels).toEqual(["pinned"]);
|
||||
});
|
||||
|
||||
test("3.3: Delete then add resolves conflict", () => {
|
||||
const variable = varStore.create("uwf/workflow/", hashC, {
|
||||
labels: ["pinned"],
|
||||
});
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
delete: ["pinned"],
|
||||
add: { pinned: "true" },
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual({ pinned: "true" });
|
||||
expect(updated.labels).toEqual([]);
|
||||
});
|
||||
|
||||
test("3.4: Simultaneous conflicting operations in same call", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA);
|
||||
|
||||
expect(() =>
|
||||
varStore.tag(variable.id, {
|
||||
add: { newkey: "value" },
|
||||
addLabels: ["newkey"],
|
||||
}),
|
||||
).toThrow(TagLabelConflictError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 4: Query - Scope Filtering", () => {
|
||||
test("4.1: List with exact scope match", () => {
|
||||
const var1 = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active" },
|
||||
});
|
||||
const var2 = varStore.create("uwf/thread/", hashB, {
|
||||
tags: { status: "completed" },
|
||||
});
|
||||
varStore.create("uwf/workflow/", hashC);
|
||||
|
||||
const results = varStore.list({ scope: "uwf/thread/" });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((v) => v.id)).toContain(var1.id);
|
||||
expect(results.map((v) => v.id)).toContain(var2.id);
|
||||
});
|
||||
|
||||
test("4.2: List with scope prefix match", () => {
|
||||
const var1 = varStore.create("uwf/thread/", hashA);
|
||||
const var2 = varStore.create("uwf/thread/", hashB);
|
||||
const var3 = varStore.create("uwf/workflow/", hashC);
|
||||
|
||||
const results = varStore.list({ scope: "uwf/" });
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.map((v) => v.id)).toContain(var1.id);
|
||||
expect(results.map((v) => v.id)).toContain(var2.id);
|
||||
expect(results.map((v) => v.id)).toContain(var3.id);
|
||||
});
|
||||
|
||||
test("4.3: List all variables (no scope filter)", () => {
|
||||
const var1 = varStore.create("uwf/thread/", hashA);
|
||||
const var2 = varStore.create("app/config/", hashB);
|
||||
|
||||
const results = varStore.list();
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((v) => v.id)).toContain(var1.id);
|
||||
expect(results.map((v) => v.id)).toContain(var2.id);
|
||||
});
|
||||
|
||||
test("4.4: List with non-matching scope returns empty", () => {
|
||||
varStore.create("uwf/thread/", hashA);
|
||||
|
||||
const results = varStore.list({ scope: "app/config/" });
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 5: Query - Tag Filtering", () => {
|
||||
test("5.1: Filter by tag key-value pair", () => {
|
||||
const var1 = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "completed" },
|
||||
});
|
||||
const var2 = varStore.create("uwf/thread/", hashB, {
|
||||
tags: { status: "completed" },
|
||||
});
|
||||
varStore.create("uwf/thread/", hashC, {
|
||||
tags: { status: "active" },
|
||||
});
|
||||
|
||||
const results = varStore.list({
|
||||
tags: { status: "completed" },
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((v) => v.id)).toContain(var1.id);
|
||||
expect(results.map((v) => v.id)).toContain(var2.id);
|
||||
});
|
||||
|
||||
test("5.2: Filter by non-existent tag returns empty", () => {
|
||||
varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active" },
|
||||
});
|
||||
|
||||
const results = varStore.list({
|
||||
tags: { nonexistent: "value" },
|
||||
});
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
test("5.3: Multiple tag filters use AND logic", () => {
|
||||
const var1 = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "completed", priority: "high" },
|
||||
});
|
||||
varStore.create("uwf/thread/", hashB, {
|
||||
tags: { status: "completed", priority: "low" },
|
||||
});
|
||||
varStore.create("uwf/thread/", hashC, {
|
||||
tags: { status: "active", priority: "high" },
|
||||
});
|
||||
|
||||
const results = varStore.list({
|
||||
tags: { status: "completed", priority: "high" },
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(var1.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 6: Query - Label Filtering", () => {
|
||||
test("6.1: Filter by label", () => {
|
||||
const var1 = varStore.create("uwf/workflow/", hashA, {
|
||||
labels: ["pinned"],
|
||||
});
|
||||
varStore.create("uwf/workflow/", hashB);
|
||||
|
||||
const results = varStore.list({
|
||||
labels: ["pinned"],
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(var1.id);
|
||||
});
|
||||
|
||||
test("6.2: Filter by non-existent label returns empty", () => {
|
||||
varStore.create("uwf/workflow/", hashA, {
|
||||
labels: ["pinned"],
|
||||
});
|
||||
|
||||
const results = varStore.list({
|
||||
labels: ["nonexistent"],
|
||||
});
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
test("6.3: Multiple label filters use AND logic", () => {
|
||||
const var1 = varStore.create("uwf/thread/", hashA, {
|
||||
labels: ["experimental", "deprecated"],
|
||||
});
|
||||
varStore.create("uwf/thread/", hashB, {
|
||||
labels: ["experimental"],
|
||||
});
|
||||
|
||||
const results = varStore.list({
|
||||
labels: ["experimental", "deprecated"],
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(var1.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 7: Query - Combined Filtering", () => {
|
||||
test("7.1: Scope + tag filter", () => {
|
||||
const var1 = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "completed" },
|
||||
});
|
||||
const var2 = varStore.create("uwf/thread/", hashB, {
|
||||
tags: { status: "completed" },
|
||||
});
|
||||
varStore.create("uwf/workflow/", hashC, {
|
||||
tags: { status: "completed" },
|
||||
});
|
||||
|
||||
const results = varStore.list({
|
||||
scope: "uwf/thread/",
|
||||
tags: { status: "completed" },
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((v) => v.id)).toContain(var1.id);
|
||||
expect(results.map((v) => v.id)).toContain(var2.id);
|
||||
});
|
||||
|
||||
test("7.2: Scope + label filter", () => {
|
||||
const var1 = varStore.create("uwf/workflow/", hashA, {
|
||||
labels: ["pinned"],
|
||||
});
|
||||
varStore.create("uwf/thread/", hashB, {
|
||||
labels: ["pinned"],
|
||||
});
|
||||
|
||||
const results = varStore.list({
|
||||
scope: "uwf/workflow/",
|
||||
labels: ["pinned"],
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(var1.id);
|
||||
});
|
||||
|
||||
test("7.3: Scope + multiple filters", () => {
|
||||
const var1 = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "completed", priority: "high" },
|
||||
});
|
||||
varStore.create("uwf/thread/", hashB, {
|
||||
tags: { status: "completed" },
|
||||
});
|
||||
varStore.create("uwf/workflow/", hashC, {
|
||||
tags: { status: "completed", priority: "high" },
|
||||
});
|
||||
|
||||
const results = varStore.list({
|
||||
scope: "uwf/",
|
||||
tags: { status: "completed", priority: "high" },
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((v) => v.id)).toContain(var1.id);
|
||||
});
|
||||
|
||||
test("7.4: Combined filters with no matches", () => {
|
||||
varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active" },
|
||||
});
|
||||
|
||||
const results = varStore.list({
|
||||
scope: "app/",
|
||||
tags: { status: "completed" },
|
||||
});
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 8: Edge Cases and Error Handling", () => {
|
||||
test("8.1: Tag operation on non-existent variable", () => {
|
||||
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
|
||||
|
||||
expect(() =>
|
||||
varStore.tag(fakeId, {
|
||||
add: { key: "value" },
|
||||
}),
|
||||
).toThrow(VariableNotFoundError);
|
||||
});
|
||||
|
||||
test("8.2: Special characters in tag keys/values", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA);
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
add: { "env:region": "prod-us_west.2" },
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual({ "env:region": "prod-us_west.2" });
|
||||
});
|
||||
|
||||
test("8.3: Unicode in tag/label names", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA);
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
add: { 语言: "中文" },
|
||||
addLabels: ["测试"],
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual({ 语言: "中文" });
|
||||
expect(updated.labels).toContain("测试");
|
||||
|
||||
// Verify persistence
|
||||
const retrieved = varStore.get(variable.id);
|
||||
expect(retrieved?.tags).toEqual({ 语言: "中文" });
|
||||
expect(retrieved?.labels).toContain("测试");
|
||||
});
|
||||
|
||||
test("8.4: Empty tag key or value", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA);
|
||||
|
||||
// Empty key
|
||||
const updated1 = varStore.tag(variable.id, {
|
||||
add: { "": "value" },
|
||||
});
|
||||
expect(updated1.tags).toEqual({ "": "value" });
|
||||
|
||||
// Empty value
|
||||
const updated2 = varStore.tag(variable.id, {
|
||||
add: { key: "" },
|
||||
});
|
||||
expect(updated2.tags.key).toBe("");
|
||||
});
|
||||
|
||||
test("8.5: Very long tag key/value", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA);
|
||||
const longKey = "k".repeat(1000);
|
||||
const longValue = "v".repeat(1000);
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
add: { [longKey]: longValue },
|
||||
});
|
||||
|
||||
expect(updated.tags[longKey]).toBe(longValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 9: Database Integrity", () => {
|
||||
test("9.1: Cascade delete for tags", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active", workflow: "solve-issue" },
|
||||
});
|
||||
|
||||
varStore.delete(variable.id);
|
||||
|
||||
// Verify variable is deleted
|
||||
const retrieved = varStore.get(variable.id);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
test("9.2: Cascade delete for labels", () => {
|
||||
const variable = varStore.create("uwf/workflow/", hashA, {
|
||||
labels: ["pinned", "archived"],
|
||||
});
|
||||
|
||||
varStore.delete(variable.id);
|
||||
|
||||
const retrieved = varStore.get(variable.id);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
test("9.3: Tag update preserves other variable data", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active" },
|
||||
});
|
||||
|
||||
varStore.tag(variable.id, {
|
||||
add: { priority: "high" },
|
||||
});
|
||||
|
||||
const retrieved = varStore.get(variable.id);
|
||||
expect(retrieved?.id).toBe(variable.id);
|
||||
expect(retrieved?.scope).toBe(variable.scope);
|
||||
expect(retrieved?.value).toBe(variable.value);
|
||||
expect(retrieved?.schema).toBe(variable.schema);
|
||||
expect(retrieved?.created).toBe(variable.created);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 10: Batch Operations and Atomicity", () => {
|
||||
test("10.1: Atomic tag operations", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active", workflow: "solve-issue" },
|
||||
});
|
||||
|
||||
const updated = varStore.tag(variable.id, {
|
||||
add: { priority: "low" },
|
||||
addLabels: ["archived"],
|
||||
delete: ["status"],
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual({
|
||||
workflow: "solve-issue",
|
||||
priority: "low",
|
||||
});
|
||||
expect(updated.labels).toContain("archived");
|
||||
});
|
||||
|
||||
test("10.2: Rollback on conflict error", () => {
|
||||
const variable = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { workflow: "solve-issue" },
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
varStore.tag(variable.id, {
|
||||
add: { priority: "high" },
|
||||
addLabels: ["workflow"], // Conflict!
|
||||
}),
|
||||
).toThrow(TagLabelConflictError);
|
||||
|
||||
// Verify NO changes applied
|
||||
const retrieved = varStore.get(variable.id);
|
||||
expect(retrieved?.tags).toEqual({ workflow: "solve-issue" });
|
||||
expect(retrieved?.labels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 11: Integration Tests", () => {
|
||||
test("11.1: Full workflow with tags and labels", async () => {
|
||||
// Create with initial tags
|
||||
const var1 = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "active" },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Add more tags
|
||||
varStore.tag(var1.id, {
|
||||
add: { priority: "high", workflow: "solve-issue" },
|
||||
});
|
||||
|
||||
// Add labels
|
||||
varStore.tag(var1.id, {
|
||||
addLabels: ["pinned"],
|
||||
});
|
||||
|
||||
// Update variable value
|
||||
const updated = varStore.update(var1.id, hashB);
|
||||
|
||||
// Verify tags/labels preserved
|
||||
expect(updated.tags).toEqual({
|
||||
status: "active",
|
||||
priority: "high",
|
||||
workflow: "solve-issue",
|
||||
});
|
||||
expect(updated.labels).toContain("pinned");
|
||||
|
||||
// Delete variable
|
||||
varStore.delete(var1.id);
|
||||
|
||||
// Verify deletion
|
||||
const retrieved = varStore.get(var1.id);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
test("11.2: Query with complex filtering", () => {
|
||||
const var1 = varStore.create("uwf/thread/", hashA, {
|
||||
tags: { status: "completed", priority: "high" },
|
||||
labels: ["archived"],
|
||||
});
|
||||
varStore.create("uwf/thread/", hashB, {
|
||||
tags: { status: "completed", priority: "low" },
|
||||
});
|
||||
varStore.create("uwf/workflow/", hashC, {
|
||||
tags: { status: "completed", priority: "high" },
|
||||
labels: ["archived"],
|
||||
});
|
||||
|
||||
const results = varStore.list({
|
||||
scope: "uwf/thread/",
|
||||
tags: { status: "completed", priority: "high" },
|
||||
labels: ["archived"],
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(var1.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
describe("Variable Type", () => {
|
||||
test("Variable type uses (name, schema) composite key", () => {
|
||||
const variable: Variable = {
|
||||
name: "config",
|
||||
schema: "ABC123DEF4567",
|
||||
value: "XYZ789GHI0123",
|
||||
created: 1234567890000,
|
||||
updated: 1234567890000,
|
||||
tags: { env: "prod" },
|
||||
labels: ["critical"],
|
||||
};
|
||||
|
||||
expect(variable.name).toBe("config");
|
||||
expect(variable.schema).toBe("ABC123DEF4567");
|
||||
// id and scope should not exist
|
||||
expect((variable as unknown as { id?: unknown }).id).toBeUndefined();
|
||||
expect((variable as unknown as { scope?: unknown }).scope).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,13 @@
|
||||
import type { Hash } from "./types.js";
|
||||
|
||||
/**
|
||||
* ULID identifier (26-character Crockford Base32)
|
||||
*/
|
||||
export type VariableId = string;
|
||||
|
||||
/**
|
||||
* Variable: mutable binding to an immutable CAS node
|
||||
* Identified by composite key (name, schema)
|
||||
*/
|
||||
export type Variable = {
|
||||
id: VariableId;
|
||||
scope: string; // hierarchical path, must end with /
|
||||
name: string; // variable name (unique per schema)
|
||||
schema: Hash; // schema hash (part of composite key)
|
||||
value: Hash; // CAS node hash
|
||||
schema: Hash; // extracted from value's CAS node.type
|
||||
created: number; // epoch ms
|
||||
updated: number; // epoch ms
|
||||
tags: Record<string, string>; // key-value pairs
|
||||
|
||||
@@ -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