Compare commits

..

1 Commits

Author SHA1 Message Date
xingyue 3a9b2bf86c fix: add containerName to reporter frontmatter for cleanup routing
Workflow validation failed because reporter's graph edges reference
{{{containerName}}} but reporter's frontmatter didn't include the field.
2026-05-31 19:07:00 +08:00
8 changed files with 215 additions and 1070 deletions
+5 -2
View File
@@ -348,6 +348,7 @@ roles:
```
4. Collect created issue numbers
5. Include the containerName from your input in your frontmatter output (needed for cleanup)
output: "List created issues. Set $status."
frontmatter:
@@ -358,13 +359,15 @@ roles:
issues:
type: array
items: { type: string }
required: [$status, issues]
containerName: { type: string }
required: [$status, issues, containerName]
- type: object
properties:
$status: { const: "partial" }
created: { type: number }
failed: { type: number }
required: [$status, created, failed]
containerName: { type: string }
required: [$status, created, failed, containerName]
cleanup:
description: "Stops and removes the Docker container"
@@ -1,303 +0,0 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 1: CAS Core 1.3 schema get returns schema JSON (snapshot) 1`] = `
"{
"type": "object",
"required": [
"name"
],
"properties": {
"age": {
"type": "number"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
}"
`;
exports[`Phase 1: CAS Core 1.4 schema list shows registered schemas 1`] = `
"8J5E9WCD54ZJZ (unnamed)
89ZQWMV9PTC7B (unnamed)
86QB0Q5VA7797 (unnamed)
AKEX4KYV98MGT (unnamed)
AGSJVKM01WNKZ (unnamed)
7XX5H51CVD9H0 (unnamed)"
`;
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"payload": {
"age": 30,
"name": "Alice",
},
"type": "7XX5H51CVD9H0",
}
`;
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `"ok"`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `""`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `"ERARPP19YJT05"`;
exports[`Phase 1: CAS Core 1.13 cat returns full node (snapshot) 1`] = `
{
"payload": {
"age": 30,
"name": "Alice",
},
"type": "7XX5H51CVD9H0",
}
`;
exports[`Phase 1: CAS Core 1.14 cat --payload returns only payload (snapshot) 1`] = `
"{
"age": 30,
"name": "Alice"
}"
`;
exports[`Phase 2: Schema Validation 2.2 schema validate on valid node returns valid 1`] = `"valid"`;
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "F68P1BZ46YDXM",
},
}
`;
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=7XX5H51CVD9H0"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
"{
"schemaHash": "7XX5H51CVD9H0",
"contentHash": "FC8WACA792B6F"
}"
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `"Name: {{ payload.name }}, Age: {{ payload.age }}"`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
"[
{
"schemaHash": "7XX5H51CVD9H0",
"preview": "Name: {{ payload.name }}, Age: {{ payload.age }}"
}
]"
`;
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
"{
"deleted": true
}"
`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 7XX5H51CVD9H0"`;
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: json-cas var set <name> <hash> [--tag <tag>...]"`;
exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`;
exports[`Phase 7: Edge Cases 7.5 schema put invalid schema errors 1`] = `"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema"`;
exports[`Phase 7: Edge Cases 7.6 no subcommand shows help text 1`] = `
"Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
schema validate <hash> Validate node against its schema
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
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
render --pipe/-p [options] Render { type, value } from stdin
cat <hash> [--payload] Output node (--payload for payload only)
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
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
--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)
--pipe, -p Read { type, value } JSON from stdin for render"
`;
+127 -3
View File
@@ -1443,6 +1443,130 @@ describe("schema put - invalid schema error handling", () => {
});
});
// Store validation tests removed - with auto-bootstrap in Phase 1a,
// stores are automatically created and bootstrapped when opened.
// Issue #55 validation is no longer applicable.
describe("Store validation - non-existent paths (Issue #55)", () => {
test("E2E-55-1: get with non-existent store reports store not found", async () => {
const fakePath = "/nonexistent/path/to/store";
const { exitCode, stderr } = await runCli(
["get", "AAAAAAAAAAAAA"],
fakePath,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
expect(stderr).toContain(fakePath);
expect(stderr).not.toContain("Node not found");
});
test("E2E-55-2: has with non-existent store reports store not found", async () => {
const fakePath = `/tmp/nonexistent-store-${Date.now()}`;
const { exitCode, stderr } = await runCli(
["has", "BBBBBBBBBBBBB"],
fakePath,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-3: verify with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["verify", "CCCCCCCCCCCCC"],
"/does/not/exist",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-4: schema get with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["schema", "get", "@string"],
"/fake/path",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-5: schema list with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["schema", "list"],
"/nonexistent/store",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-6: walk with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["walk", "DDDDDDDDDDDDD"],
"/tmp/missing-store",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-7: cat with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["cat", "EEEEEEEEEEEEE"],
"/no/such/dir",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-8: refs with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["refs", "FFFFFFFFFFFFF"],
"/nonexistent",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-9: existing store works normally (regression)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const payloadFile = join(tmpStore, "test.json");
writeFileSync(payloadFile, JSON.stringify("test"));
const { stdout: hash } = await runCli(
["put", "@string", payloadFile],
tmpStore,
);
const { exitCode, stdout, stderr } = await runCli(
["get", hash.trim()],
tmpStore,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const node = JSON.parse(stdout);
expect(node).toHaveProperty("type");
expect(node).toHaveProperty("payload", "test");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("E2E-55-10: init creates directory (should not validate)", async () => {
const newStore = join(tmpdir(), `new-store-${Date.now()}`);
try {
const { exitCode, stdout, stderr } = await runCli(["init"], newStore);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const { existsSync } = await import("node:fs");
expect(existsSync(newStore)).toBe(true);
} finally {
rmSync(newStore, { recursive: true, force: true });
}
});
});
-549
View File
@@ -1,549 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const entrypoint = resolve(import.meta.dir, "index.ts");
let tmpStore: string;
let varDbPath: string;
// Shared hashes across phases
let typeHash: string;
let nodeHash: string;
beforeAll(() => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
/**
* Parse JSON and strip volatile fields (timestamp, created, updated)
* so snapshots are stable across runs.
*/
function stripVolatile(json: string): unknown {
const strip = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(strip);
if (v !== null && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (k === "timestamp" || k === "created" || k === "updated") continue;
out[k] = strip(val);
}
return out;
}
return v;
};
return strip(JSON.parse(json));
}
// ---- Phase 1: CAS Core ----
describe("Phase 1: CAS Core", () => {
test("1.1 schema put returns type hash (auto-bootstraps store)", async () => {
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
additionalProperties: false,
}),
);
const { stdout, exitCode } = await runCli(["schema", "put", schemaFile]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
typeHash = stdout;
});
test("1.3 schema get returns schema JSON (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["schema", "get", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.4 schema list shows registered schemas", async () => {
const { stdout, exitCode } = await runCli(["schema", "list"]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
expect(stdout).toContain(typeHash);
});
test("1.5 put returns node hash", async () => {
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout, exitCode } = await runCli(["put", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
nodeHash = stdout;
});
test("1.6 get returns node JSON (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["get", nodeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.7 has returns true for existing node", async () => {
const { stdout, exitCode } = await runCli(["has", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("true");
});
test("1.8 has returns false for non-existing hash", async () => {
const { stdout, exitCode } = await runCli(["has", "AAAAAAAAAAAAA"]);
expect(exitCode).toBe(0);
expect(stdout).toBe("false");
});
test("1.9 verify returns ok for valid node", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.10 refs lists direct references (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["refs", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.11 walk shows traversal tree (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["walk", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.12 hash dry-run returns same hash as put", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stdout, exitCode } = await runCli(["hash", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(stdout).toBe(nodeHash);
});
test("1.13 cat returns full node (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["cat", nodeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.14 cat --payload returns only payload (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["cat", nodeHash, "--payload"]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
const parsed = JSON.parse(stdout) as Record<string, unknown>;
expect(parsed).not.toHaveProperty("type");
expect(parsed).toHaveProperty("name", "Alice");
});
});
// ---- Phase 2: Schema Validation ----
describe("Phase 2: Schema Validation", () => {
test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => {
const badFile = join(tmpStore, "bad-node.json");
writeFileSync(badFile, JSON.stringify({ name: 123 }));
const { stdout, stderr, exitCode } = await runCli([
"put",
typeHash,
badFile,
]);
expect(exitCode).not.toBe(0);
expect(stdout).toBe("");
expect(stderr).toContain("Validation failed");
expect(stderr).toContain(typeHash);
// Do NOT snapshot stderr — it embeds a machine-specific tmp path
});
test("2.2 schema validate on valid node returns valid", async () => {
const { stdout, exitCode } = await runCli(["schema", "validate", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("2.3 put against non-existent schema hash fails", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stderr, exitCode } = await runCli([
"put",
"AAAAAAAAAAAAA",
nodeFile,
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 3: Variable System ----
describe("Phase 3: Variable System", () => {
test("3.1 var set creates variable", async () => {
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
nodeHash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.2 var get returns variable", async () => {
const { stdout, exitCode } = await runCli([
"var",
"get",
"myapp/config",
"--schema",
typeHash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain(nodeHash);
});
test("3.3 var list shows all variables", async () => {
const { stdout, exitCode } = await runCli(["var", "list"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain("myapp/config");
});
test("3.4 var list prefix filters by prefix", async () => {
const { stdout, exitCode } = await runCli(["var", "list", "myapp/"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain("myapp/config");
});
test("3.5 var set upsert updates existing variable", async () => {
const node2File = join(tmpStore, "node2.json");
writeFileSync(node2File, JSON.stringify({ name: "Bob", age: 25 }));
const { stdout: node2Hash } = await runCli(["put", typeHash, node2File]);
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
node2Hash.trim(),
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
// Restore original value
await runCli(["var", "set", "myapp/config", nodeHash]);
});
test("3.6 var tag adds kv tag and label", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"myapp/config",
"--schema",
typeHash,
"env:prod",
"important",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.7 var list --tag env:prod filters by kv tag", async () => {
const { stdout, exitCode } = await runCli([
"var",
"list",
"--tag",
"env:prod",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("myapp/config");
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.8 var list --tag important filters by label", async () => {
const { stdout, exitCode } = await runCli([
"var",
"list",
"--tag",
"important",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("myapp/config");
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.9 var tag remove deletes label", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"myapp/config",
"--schema",
typeHash,
":important",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
// Verify label is gone
const { stdout: listOut } = await runCli([
"var",
"list",
"--tag",
"important",
]);
expect(listOut).not.toContain("myapp/config");
});
test("3.10 var delete removes variable", async () => {
const { exitCode, stdout } = await runCli([
"var",
"delete",
"myapp/config",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.11 var get deleted variable returns not found", async () => {
const { stderr, exitCode } = await runCli([
"var",
"get",
"myapp/config",
"--schema",
typeHash,
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 4: Template System ----
describe("Phase 4: Template System", () => {
test("4.1 template set registers template", async () => {
const tmplFile = join(tmpStore, "test.liquid");
writeFileSync(tmplFile, "Name: {{ payload.name }}, Age: {{ payload.age }}");
const { exitCode, stdout } = await runCli([
"template",
"set",
typeHash,
tmplFile,
]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("4.2 template get returns template text", async () => {
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Name: {{ payload.name }}, Age: {{ payload.age }}");
expect(stdout).toMatchSnapshot();
});
test("4.3 template list shows registered templates", async () => {
const { stdout, exitCode } = await runCli(["template", "list"]);
expect(exitCode).toBe(0);
expect(stdout).toContain(typeHash);
expect(stdout).toMatchSnapshot();
});
test("4.4 template delete removes template", async () => {
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("4.5 template get deleted template returns not found", async () => {
const { stderr, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 5: Render ----
describe("Phase 5: Render", () => {
beforeAll(async () => {
const tmplFile = join(tmpStore, "render-template.liquid");
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
await runCli(["template", "set", typeHash, tmplFile]);
});
test("5.1 render fills payload variables", async () => {
const { stdout, exitCode } = await runCli(["render", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Hello Alice!");
expect(stdout).toMatchSnapshot();
});
test("5.2 render --resolution with different value", async () => {
const { stdout, exitCode } = await runCli([
"render",
nodeHash,
"--resolution",
"0.5",
]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("5.3 render non-existent hash fails with error", async () => {
const { stderr, exitCode } = await runCli(["render", "ZZZZZZZZZZZZZ"]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Node not found");
expect(stderr).toContain("ZZZZZZZZZZZZZ");
});
});
// ---- Phase 6: GC ----
describe("Phase 6: GC", () => {
let gcNodeHash: string;
beforeAll(async () => {
// Create a fresh node for GC tests (independent of shared nodeHash)
const gcNodeFile = join(tmpStore, "gc-node.json");
writeFileSync(gcNodeFile, JSON.stringify({ name: "GcAlice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, gcNodeFile]);
gcNodeHash = stdout.trim();
// Set a var referencing this node so it survives GC during Phase 6
await runCli(["var", "set", "gc-test/ref", gcNodeHash]);
});
test("6.1 gc runs without error", async () => {
const { exitCode, stdout } = await runCli(["gc"]);
expect(exitCode).toBe(0);
// Assert structural shape only — exact counts depend on phase history
const result = JSON.parse(stdout) as Record<string, unknown>;
expect(typeof result.total).toBe("number");
expect(typeof result.reachable).toBe("number");
expect(typeof result.collected).toBe("number");
expect(typeof result.scanned).toBe("number");
expect(result.total as number).toBeGreaterThanOrEqual(
result.reachable as number,
);
});
test("6.2 gc preserves node referenced by a var", async () => {
const { exitCode } = await runCli(["gc"]);
expect(exitCode).toBe(0);
const { stdout } = await runCli(["has", gcNodeHash]);
expect(stdout).toBe("true");
});
test("6.3 gc reclaims orphan node", async () => {
const orphanFile = join(tmpStore, "orphan.json");
writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 }));
const { stdout: orphanHashRaw } = await runCli([
"put",
typeHash,
orphanFile,
]);
const orphanHash = orphanHashRaw.trim();
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
expect(beforeGc).toBe("true");
await runCli(["gc"]);
const { stdout: afterGc } = await runCli(["has", orphanHash]);
expect(afterGc).toBe("false");
});
});
// ---- Phase 7: Edge Cases ----
describe("Phase 7: Edge Cases", () => {
test("7.1 get non-existent hash errors gracefully", async () => {
const { stderr, exitCode } = await runCli(["get", "AAAAAAAAAAAAA"]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
test("7.2 put with non-existent file errors with ENOENT", async () => {
const { stderr, exitCode } = await runCli([
"put",
typeHash,
"/nonexistent/file.json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("ENOENT");
});
test("7.3 var set empty name errors", async () => {
const { stderr, exitCode } = await runCli(["var", "set", "", nodeHash]);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
expect(stderr).toMatchSnapshot();
});
test("7.4 var set name with invalid chars errors", async () => {
const { stderr, exitCode } = await runCli([
"var",
"set",
"invalid name!",
nodeHash,
]);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
expect(stderr).toMatchSnapshot();
});
test("7.5 schema put invalid schema errors", async () => {
const badSchemaFile = join(tmpStore, "bad-schema.json");
writeFileSync(badSchemaFile, JSON.stringify({ type: "invalid" }));
const { stdout, stderr, exitCode } = await runCli([
"schema",
"put",
badSchemaFile,
]);
expect(exitCode).not.toBe(0);
expect(stdout).toBe("");
expect(stderr).toMatchSnapshot();
});
test("7.6 no subcommand shows help text", async () => {
const { stdout, stderr, exitCode: _exitCode } = await runCli([]);
const combined = stdout + stderr;
expect(combined.length).toBeGreaterThan(0);
expect(combined).toMatchSnapshot();
expect(combined.toLowerCase()).toContain("usage");
});
test("7.7 --store path is a file errors", async () => {
const fileAsStore = join(tmpStore, "not-a-directory");
writeFileSync(fileAsStore, "test");
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
fileAsStore,
"--var-db",
varDbPath,
"get",
"AAAAAAAAAAAAA",
],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stderr = (await new Response(proc.stderr).text()).trim();
expect(exitCode).not.toBe(0);
expect(stderr).toContain("not a directory");
});
});
+80 -37
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
import { existsSync, 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";
@@ -24,7 +24,7 @@ import {
verify,
walk,
} from "@uncaged/json-cas";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Argument parsing ----
@@ -113,16 +113,26 @@ function readJsonFile(file: string): unknown {
}
/**
* Open the filesystem-backed CAS store.
* Automatically creates directory and bootstraps if needed.
* Validate that the store directory exists.
* Dies with a clear error message if not found.
*/
async function openStore(): Promise<Store> {
const fullPath = resolve(storePath);
return await openFsStore(fullPath);
function validateStoreExists(path: string): void {
if (!existsSync(path)) {
die(`Store not found at ${path}`);
}
}
async function openVarStore(): Promise<VariableStore> {
const store = await openStore();
function openStore(shouldCreate = false): Store {
const fullPath = resolve(storePath);
if (!shouldCreate) {
validateStoreExists(fullPath);
}
return createFsStore(fullPath);
}
function openVarStore(): VariableStore {
const store = openStore(true);
mkdirSync(resolve(storePath), { recursive: true });
return createVariableStore(resolve(varDbPath), store);
}
@@ -131,9 +141,12 @@ async function openVarStore(): Promise<VariableStore> {
* If the input starts with @, resolve it via bootstrap
* Otherwise, return the hash as-is
*/
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
async function resolveTypeHash(
typeHashOrAlias: string,
shouldCreate = false,
): Promise<Hash> {
if (typeHashOrAlias.startsWith("@")) {
const store = await openStore();
const store = openStore(shouldCreate);
const builtinSchemas = await bootstrap(store);
const resolvedHash = builtinSchemas[typeHashOrAlias];
if (!resolvedHash) {
@@ -149,7 +162,7 @@ async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
* This is the type hash used in JSON envelopes
*/
async function getVariableSchemaHash(): Promise<Hash> {
const store = await openStore();
const store = openStore();
// Define the Variable JSON Schema (updated for new model with composite key)
const variableSchema: JSONSchema = {
@@ -227,11 +240,27 @@ function parseTagsLabels(args: string[]): {
// ---- Commands ----
async function cmdInit(): Promise<void> {
const dir = resolve(storePath);
mkdirSync(dir, { recursive: true });
const store = createFsStore(dir);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
console.log(metaHash);
}
async function cmdBootstrap(): Promise<void> {
const store = openStore(true);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
console.log(metaHash);
}
async function cmdSchemaPut(args: string[]): Promise<void> {
const file = args[0];
if (!file) die("Usage: json-cas schema put <file.json>");
const schema = readJsonFile(file) as JSONSchema;
const store = await openStore();
const store = openStore(true);
try {
const hash = await putSchema(store, schema);
console.log(hash);
@@ -247,14 +276,14 @@ async function cmdSchemaGet(args: string[]): Promise<void> {
const hashOrAlias = args[0];
if (!hashOrAlias) die("Usage: json-cas schema get <type-hash>");
const hash = await resolveTypeHash(hashOrAlias);
const store = await openStore();
const store = openStore();
const schema = getSchema(store, hash);
if (schema === null) die(`Schema not found: ${hashOrAlias}`);
out(schema);
}
async function cmdSchemaList(): Promise<void> {
const store = await openStore();
const store = openStore();
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (!metaHash) throw new Error("Meta-schema not found");
@@ -275,7 +304,7 @@ async function cmdSchemaList(): Promise<void> {
async function cmdSchemaValidate(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas schema validate <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const valid = validate(store, node);
@@ -287,9 +316,9 @@ async function cmdPut(args: string[]): Promise<void> {
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas put <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
const payload = readJsonFile(file);
const store = await openStore();
const store = openStore(true);
// Check if schema exists
const schema = getSchema(store, typeHash);
@@ -314,7 +343,7 @@ async function cmdPut(args: string[]): Promise<void> {
async function cmdGet(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas get <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
out(node);
@@ -323,14 +352,14 @@ async function cmdGet(args: string[]): Promise<void> {
async function cmdHas(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas has <hash>");
const store = await openStore();
const store = openStore();
console.log(String(store.has(hash)));
}
async function cmdVerify(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas verify <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const ok = await verify(hash, node);
@@ -340,7 +369,7 @@ async function cmdVerify(args: string[]): Promise<void> {
async function cmdRefs(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas refs <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const refHashes = refs(store, node);
@@ -352,7 +381,7 @@ async function cmdRefs(args: string[]): Promise<void> {
async function cmdWalk(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas walk <hash> [--format tree]");
const store = await openStore();
const store = openStore();
const format = flags.format;
if (format === "tree") {
@@ -393,7 +422,7 @@ async function cmdHash(args: string[]): Promise<void> {
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas hash <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
const payload = readJsonFile(file);
const hash = await computeHash(typeHash, payload);
console.log(hash);
@@ -413,7 +442,7 @@ async function cmdRender(args: string[]): Promise<void> {
);
}
const store = await openStore();
const store = openStore();
// Parse numeric options
const resolution =
@@ -488,7 +517,7 @@ async function cmdRender(args: string[]): Promise<void> {
);
process.stdout.write(output);
} else {
const varStore = await openVarStore();
const varStore = openVarStore();
const output = await renderAsync(store, hash, {
resolution,
decay,
@@ -512,7 +541,7 @@ async function cmdRender(args: string[]): Promise<void> {
async function cmdCat(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas cat <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
if (flags.payload === true) {
@@ -531,7 +560,7 @@ async function cmdVarSet(args: string[]): Promise<void> {
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
}
const varStore = await openVarStore();
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
@@ -582,7 +611,7 @@ async function cmdVarGet(args: string[]): Promise<void> {
die("Usage: json-cas var get <name> --schema <hash>");
}
const varStore = await openVarStore();
const varStore = openVarStore();
try {
const variable = varStore.get(name, schema);
@@ -604,7 +633,7 @@ async function cmdVarDelete(args: string[]): Promise<void> {
die("Usage: json-cas var delete <name> [--schema <hash>]");
}
const varStore = await openVarStore();
const varStore = openVarStore();
try {
if (schema !== undefined) {
@@ -641,7 +670,7 @@ async function cmdVarTag(args: string[]): Promise<void> {
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const varStore = await openVarStore();
const varStore = openVarStore();
try {
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
@@ -673,7 +702,7 @@ async function cmdVarList(args: string[]): Promise<void> {
const schema = flags.schema as string | undefined;
const tagFlags = flags.tag;
const varStore = await openVarStore();
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
@@ -715,7 +744,8 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
}
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -786,7 +816,8 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
die("Usage: json-cas template get <schema-hash>");
}
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -812,7 +843,8 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
}
async function cmdTemplateList(_args: string[]): Promise<void> {
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -852,7 +884,8 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
die("Usage: json-cas template delete <schema-hash>");
}
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -872,7 +905,7 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
}
async function cmdGc(_args: string[]): Promise<void> {
const store = await openStore();
const store = createFsStore(storePath);
const varStore = createVariableStore(varDbPath, store);
try {
@@ -888,6 +921,8 @@ function printUsage(): void {
Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
init Create store dir and write bootstrap seed
bootstrap Write meta-schema seed, print hash
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
@@ -936,6 +971,14 @@ if (!cmd) {
}
switch (cmd) {
case "init":
await cmdInit();
break;
case "bootstrap":
await cmdBootstrap();
break;
case "schema": {
const [sub, ...subRest] = rest;
switch (sub) {
+1 -1
View File
@@ -1 +1 @@
export { createFsStore, openStore } from "./store.js";
export { createFsStore } from "./store.js";
+2 -119
View File
@@ -1,11 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
existsSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasNode } from "@uncaged/json-cas";
@@ -16,7 +10,7 @@ import {
verify,
} from "@uncaged/json-cas";
import { createFsStore, openStore } from "./store.js";
import { createFsStore } from "./store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "json-cas-fs-test-"));
@@ -318,114 +312,3 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// openStore – async with auto-bootstrap
// ──────────────────────────────────────────────────────────────────────────────
describe("openStore – async with auto-bootstrap", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("openStore returns Promise<Store>", async () => {
const store = await openStore(dir);
expect(store).toBeDefined();
expect(typeof store.put).toBe("function");
expect(typeof store.get).toBe("function");
});
test("openStore auto-creates directory when it doesn't exist", async () => {
const nested = join(dir, "sub", "nested", "store");
expect(existsSync(nested)).toBe(false);
const store = await openStore(nested);
expect(existsSync(nested)).toBe(true);
// Verify store works
const typeHash = await computeSelfHash({ name: "t" });
const hash = await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
});
test("openStore works when directory already exists", async () => {
// Pre-create the directory
const store1 = await openStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
await store1.put(typeHash, { x: 1 });
// Open again
const store2 = await openStore(dir);
expect(store2.listByType(typeHash)).toHaveLength(1);
});
test("openStore throws error when path exists but is not a directory", async () => {
const filePath = join(dir, "not-a-dir");
writeFileSync(filePath, "test");
await expect(openStore(filePath)).rejects.toThrow();
});
test("openStore auto-bootstraps on first open (empty directory)", async () => {
const store = await openStore(dir);
// Check that bootstrap schemas exist
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
expect(metaHash).toBeDefined();
expect(store.has(metaHash as string)).toBe(true);
// Verify all core schemas exist
expect(store.has(builtinSchemas["@string"] as string)).toBe(true);
expect(store.has(builtinSchemas["@number"] as string)).toBe(true);
expect(store.has(builtinSchemas["@object"] as string)).toBe(true);
expect(store.has(builtinSchemas["@array"] as string)).toBe(true);
expect(store.has(builtinSchemas["@bool"] as string)).toBe(true);
expect(store.has(builtinSchemas["@schema"] as string)).toBe(true);
});
test("openStore bootstrap is idempotent on subsequent opens", async () => {
const store1 = await openStore(dir);
const schemas1 = await bootstrap(store1);
const count1 = store1.listAll().length;
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
const count2 = store2.listAll().length;
// Same schemas, same count
expect(schemas1).toEqual(schemas2);
expect(count1).toBe(count2);
});
test("openStore works on already-bootstrapped store", async () => {
// Bootstrap manually first
const store1 = createFsStore(dir);
const schemas1 = await bootstrap(store1);
// Open with openStore
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
expect(schemas1).toEqual(schemas2);
});
test("openStore auto-bootstraps old store without bootstrap", async () => {
// Create a store with some data but no bootstrap
const store1 = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "custom" });
await store1.put(typeHash, { data: "old" });
// Open with openStore - should auto-bootstrap
const store2 = await openStore(dir);
const schemas = await bootstrap(store2);
expect(store2.has(schemas["@schema"] as string)).toBe(true);
// Old data still exists
expect(store2.listByType(typeHash)).toHaveLength(1);
});
});
-56
View File
@@ -5,7 +5,6 @@ import {
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
@@ -14,7 +13,6 @@ import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
bootstrap,
cborEncode,
computeHash,
computeSelfHash,
@@ -221,57 +219,3 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return store;
}
/**
* Open a filesystem-backed CAS store with automatic directory creation and bootstrap.
* This is an async function that:
* 1. Creates the directory (with recursive: true) if it doesn't exist
* 2. Validates that the path is actually a directory (not a file)
* 3. Creates the store
* 4. Runs bootstrap (which is idempotent)
*
* @param dir - The directory path for the store
* @returns A Promise resolving to the BootstrapCapableStore
* @throws Error if the path exists but is not a directory
*/
export async function openStore(dir: string): Promise<BootstrapCapableStore> {
// Create directory if it doesn't exist
try {
mkdirSync(dir, { recursive: true });
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "EACCES") {
throw new Error(`Permission denied: cannot access store at ${dir}`);
}
if (nodeError.code === "ENOTDIR") {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
}
throw error;
}
// Validate that the path is a directory
try {
const stats = statSync(dir);
if (!stats.isDirectory()) {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
throw new Error(`Store not found at ${dir}`);
}
}
throw error;
}
// Create the store
const store = createFsStore(dir);
// Bootstrap (idempotent)
await bootstrap(store);
return store;
}