Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb2041e29c | |||
| 318df9b67f | |||
| 8bfcdfef25 | |||
| 55144d0e4a | |||
| 8ac13e691e | |||
| 1d08c1bf4d | |||
| 0d09e3cb80 | |||
| ca4fe8c3ac | |||
| 58f57b0dd8 | |||
| edff831e87 | |||
| 054b5d9308 | |||
| bdb77fd1e3 | |||
| b7fc03fa16 | |||
| 9367a4141a | |||
| bfec74fe8e |
@@ -32,7 +32,7 @@ Use the in-memory store for tests and embedded apps, the filesystem store for pe
|
||||
|-------|---------|------|
|
||||
| Core | `@uncaged/json-cas` | Hashing, schemas, stores, verify, bootstrap |
|
||||
| Storage | `@uncaged/json-cas-fs` | Filesystem-backed `Store` |
|
||||
| CLI | `@uncaged/cli-json-cas` | `json-cas` command-line tool |
|
||||
| CLI | `@uncaged/cli-json-cas` | `ucas` command-line tool |
|
||||
|
||||
## Packages
|
||||
|
||||
@@ -40,7 +40,7 @@ Use the in-memory store for tests and embedded apps, the filesystem store for pe
|
||||
|---------|-------------|------|
|
||||
| [`@uncaged/json-cas`](packages/json-cas/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
|
||||
| [`@uncaged/json-cas-fs`](packages/json-cas-fs/README.md) | Filesystem-backed CAS store | lib |
|
||||
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`json-cas` binary) | cli |
|
||||
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`ucas` binary) | cli |
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -88,7 +88,7 @@ Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Binary: `json-cas` (also aliased `ucas`, from `@uncaged/cli-json-cas`). Default store:
|
||||
Binary: `ucas` (from `@uncaged/cli-json-cas`; legacy alias `json-cas` is deprecated). Default store:
|
||||
`~/.uncaged/json-cas`. The store is auto-created and bootstrapped on first use — there is
|
||||
no `init`/`bootstrap` command, and schemas are ordinary `@schema`-typed nodes (`ucas put
|
||||
@schema file.json`), so there is no `schema` subcommand.
|
||||
@@ -107,7 +107,7 @@ raw (non-envelope) text.
|
||||
```
|
||||
|
||||
```
|
||||
Usage: json-cas [--store <path>] [--json] <command> [args]
|
||||
Usage: ucas [--store <path>] [--json] <command> [args]
|
||||
|
||||
Commands (all emit a { type, value } envelope unless noted):
|
||||
put <type-hash> <file.json> Store node (value = hash) (@output/put)
|
||||
@@ -120,6 +120,8 @@ Commands (all emit a { type, value } envelope unless noted):
|
||||
render <hash> [options] Render node as text (raw output)
|
||||
render --pipe/-p [options] Render a piped envelope (raw output)
|
||||
list --type <hash-or-alias> Hashes for a type (value = list) (@output/list)
|
||||
list-meta Meta-schema hashes (@output/list-meta)
|
||||
list-schema All schema hashes (@output/list-schema)
|
||||
var set|get|delete|tag|list ... Variable CRUD (@output/var-*)
|
||||
template set|get|list|delete ... Output-template CRUD (@output/template-*)
|
||||
gc Garbage collection (@output/gc)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,630 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, 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));
|
||||
}
|
||||
|
||||
/** Extract the `value` field from a { type, value } envelope JSON string. */
|
||||
function envValue(json: string): unknown {
|
||||
return (JSON.parse(json) as { value: unknown }).value;
|
||||
}
|
||||
|
||||
/** Run a CLI command feeding `stdin` to its standard input. */
|
||||
async function runCliWithStdin(
|
||||
args: string[],
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
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 };
|
||||
}
|
||||
|
||||
// ---- Phase 1: CAS Core ----
|
||||
|
||||
describe("Phase 1: CAS Core", () => {
|
||||
test("1.1 init + put with @object 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,
|
||||
}),
|
||||
);
|
||||
// Use putSchema via the library to register schema, since CLI schema put is removed
|
||||
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||
const { putSchema } = await import("@uncaged/json-cas");
|
||||
const store = await openFsStore(tmpStore);
|
||||
const hash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
typeHash = hash;
|
||||
expect(typeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
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(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
nodeHash = envValue(stdout) as string;
|
||||
});
|
||||
|
||||
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(envValue(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(envValue(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(stripVolatile(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(envValue(stdout)).toBe(nodeHash);
|
||||
});
|
||||
|
||||
test("1.13 list --type returns nodes of that type", async () => {
|
||||
const { stdout, exitCode } = await runCli(["list", "--type", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toContain(nodeHash);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 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 verify on valid node returns ok (hash + schema)", async () => {
|
||||
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toBe("ok");
|
||||
});
|
||||
|
||||
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: node2Out } = await runCli(["put", typeHash, node2File]);
|
||||
const node2Hash = envValue(node2Out) as string;
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"set",
|
||||
"myapp/config",
|
||||
node2Hash,
|
||||
]);
|
||||
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(stripVolatile(stdout)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("4.2 template get returns template text", async () => {
|
||||
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toBe(
|
||||
"Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
);
|
||||
expect(stripVolatile(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(stripVolatile(stdout)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("4.4 template delete removes template", async () => {
|
||||
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(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 = envValue(stdout) as string;
|
||||
// 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 = envValue(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(envValue(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: orphanOut } = await runCli(["put", typeHash, orphanFile]);
|
||||
const orphanHash = envValue(orphanOut) as string;
|
||||
|
||||
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
|
||||
expect(envValue(beforeGc)).toBe(true);
|
||||
|
||||
await runCli(["gc"]);
|
||||
const { stdout: afterGc } = await runCli(["has", orphanHash]);
|
||||
expect(envValue(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 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.6 --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");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Phase 8: Pipe Composition ----
|
||||
//
|
||||
// Every JSON command emits a { type, value } envelope, so its stdout can be
|
||||
// fed straight into `render --pipe` (which renders the envelope value) or into
|
||||
// any downstream JSON consumer. These tests verify the envelopes compose
|
||||
// end-to-end.
|
||||
|
||||
describe("Phase 8: Pipe Composition", () => {
|
||||
test("8.1 put | render -p expands the stored hash to its content", async () => {
|
||||
const nodeFile = join(tmpStore, "pipe-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Bob", age: 42 }));
|
||||
|
||||
const { stdout: putOut, exitCode: putExit } = await runCli([
|
||||
"put",
|
||||
typeHash,
|
||||
nodeFile,
|
||||
]);
|
||||
expect(putExit).toBe(0);
|
||||
|
||||
// The put envelope value is a cas_ref hash; render -p dereferences it and
|
||||
// renders the stored node's payload.
|
||||
const { stdout, exitCode } = await runCliWithStdin(
|
||||
["render", "--pipe"],
|
||||
putOut,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Bob");
|
||||
});
|
||||
|
||||
test("8.2 gc | render -p renders the gc stats", async () => {
|
||||
const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]);
|
||||
expect(gcExit).toBe(0);
|
||||
|
||||
const { stdout, exitCode } = await runCliWithStdin(
|
||||
["render", "--pipe"],
|
||||
gcOut,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
// gc value is an object { total, reachable, collected, scanned }
|
||||
expect(stdout).toContain("total:");
|
||||
});
|
||||
|
||||
test("8.3 list --type @schema emits a parseable envelope of hashes", async () => {
|
||||
const { stdout, exitCode } = await runCli(["list", "--type", "@schema"]);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Downstream consumers (jq, etc.) read the `value` array of hashes.
|
||||
const value = envValue(stdout) as string[];
|
||||
expect(Array.isArray(value)).toBe(true);
|
||||
for (const hash of value) {
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("8.4 list --type @schema | render -p expands the schema list", async () => {
|
||||
const { stdout: listOut } = await runCli(["list", "--type", "@schema"]);
|
||||
// list result items are cas_ref hashes; render -p dereferences each one
|
||||
// and renders the schema contents.
|
||||
const { stdout, exitCode } = await runCliWithStdin(
|
||||
["render", "--pipe"],
|
||||
listOut,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("8.5 render <hash> uses a registered template", async () => {
|
||||
// Register a template for the schema, then render a fresh node by hash.
|
||||
const tmplFile = join(tmpStore, "pipe-render.liquid");
|
||||
writeFileSync(tmplFile, "Person: {{ payload.name }} ({{ payload.age }})");
|
||||
const { exitCode: setExit } = await runCli([
|
||||
"template",
|
||||
"set",
|
||||
typeHash,
|
||||
tmplFile,
|
||||
]);
|
||||
expect(setExit).toBe(0);
|
||||
|
||||
const nodeFile = join(tmpStore, "pipe-render-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Carol", age: 25 }));
|
||||
const { stdout: putOut } = await runCli(["put", typeHash, nodeFile]);
|
||||
const freshHash = envValue(putOut) as string;
|
||||
|
||||
const { stdout, exitCode } = await runCli(["render", freshHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toBe("Person: Carol (25)");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getSchema,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
putSchema,
|
||||
refs,
|
||||
renderAsync,
|
||||
renderDirect,
|
||||
@@ -74,6 +75,8 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
} else {
|
||||
flags[key] = true;
|
||||
}
|
||||
} else if (arg === "-p") {
|
||||
flags.p = true;
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
@@ -112,6 +115,22 @@ function readJsonFile(file: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
async function readStdinJson(): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
const input = Buffer.concat(chunks).toString("utf-8").trim();
|
||||
if (!input) {
|
||||
die("No input on stdin. Pipe JSON content.");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return die("Invalid JSON on stdin.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the filesystem-backed CAS store.
|
||||
* Automatically creates directory and bootstraps if needed.
|
||||
@@ -179,14 +198,36 @@ function parseTagsLabels(args: string[]): {
|
||||
// ---- Commands ----
|
||||
|
||||
async function cmdPut(args: string[]): Promise<void> {
|
||||
const isPipe = flags.pipe === true || flags.p === true;
|
||||
const typeHashOrAlias = args[0];
|
||||
const file = args[1];
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas put <type-hash> <file.json>");
|
||||
const file = isPipe ? undefined : args[1];
|
||||
if (!typeHashOrAlias || (!isPipe && !file))
|
||||
die(
|
||||
"Usage: json-cas put <type-hash> <file.json>\n json-cas put <type-hash> --pipe/-p",
|
||||
);
|
||||
if (isPipe && args[1])
|
||||
die("Cannot use --pipe/-p with a file argument. Use one or the other.");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
const payload = readJsonFile(file);
|
||||
const payload = isPipe ? await readStdinJson() : readJsonFile(file as string);
|
||||
const store = await openStore();
|
||||
|
||||
// Schema nodes: use putSchema() which validates via isValidSchema() (recursive)
|
||||
// instead of ajv against meta-schema (which can't express recursive constraints)
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (typeHash === metaHash) {
|
||||
try {
|
||||
const hash = await putSchema(store, payload as Record<string, unknown>);
|
||||
out(await wrapEnvelope(store, "@output/put", hash));
|
||||
} catch (_e) {
|
||||
console.error(
|
||||
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if schema exists
|
||||
const schema = getSchema(store, typeHash);
|
||||
if (schema === null) {
|
||||
@@ -293,12 +334,17 @@ async function cmdWalk(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdHash(args: string[]): Promise<void> {
|
||||
const isPipe = flags.pipe === true || flags.p === true;
|
||||
const typeHashOrAlias = args[0];
|
||||
const file = args[1];
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas hash <type-hash> <file.json>");
|
||||
const file = isPipe ? undefined : args[1];
|
||||
if (!typeHashOrAlias || (!isPipe && !file))
|
||||
die(
|
||||
"Usage: json-cas hash <type-hash> <file.json>\n json-cas hash <type-hash> --pipe/-p",
|
||||
);
|
||||
if (isPipe && args[1])
|
||||
die("Cannot use --pipe/-p with a file argument. Use one or the other.");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
const payload = readJsonFile(file);
|
||||
const payload = isPipe ? await readStdinJson() : readJsonFile(file as string);
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
const store = await openStore();
|
||||
out(await wrapEnvelope(store, "@output/hash", hash));
|
||||
@@ -777,6 +823,18 @@ async function cmdList(_args: string[]): Promise<void> {
|
||||
out(await wrapEnvelope(store, "@output/list", hashes));
|
||||
}
|
||||
|
||||
async function cmdListMeta(_args: string[]): Promise<void> {
|
||||
const store = await openStore();
|
||||
const hashes = store.listMeta();
|
||||
out(await wrapEnvelope(store, "@output/list-meta", hashes));
|
||||
}
|
||||
|
||||
async function cmdListSchema(_args: string[]): Promise<void> {
|
||||
const store = await openStore();
|
||||
const hashes = store.listSchemas();
|
||||
out(await wrapEnvelope(store, "@output/list-schema", hashes));
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`\
|
||||
Usage: json-cas [--store <path>] [--json] <command> [args]
|
||||
@@ -786,16 +844,18 @@ command's @output/* schema (shown in parentheses); pipe any envelope into
|
||||
\`render -p\` to render its value (cas_ref hashes are expanded).
|
||||
|
||||
Commands:
|
||||
put <type-hash> <file.json> Store node, print envelope (value=hash) (@output/put)
|
||||
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
|
||||
get <hash> Print node as envelope (@output/get)
|
||||
has <hash> Print envelope (value=boolean) (@output/has)
|
||||
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
|
||||
refs <hash> List direct cas_ref edges (@output/refs)
|
||||
walk <hash> [--format tree] Recursive traversal (@output/walk)
|
||||
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
|
||||
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
|
||||
render <hash> [options] Render node as text with resolution decay (raw output)
|
||||
render --pipe/-p [options] Render { type, value } from stdin (raw output)
|
||||
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
|
||||
list-meta List meta-schema hashes (value=string[]) (@output/list-meta)
|
||||
list-schema List all schema hashes (value=string[]) (@output/list-schema)
|
||||
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
|
||||
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
|
||||
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
|
||||
@@ -817,7 +877,7 @@ Flags:
|
||||
--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`);
|
||||
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)`);
|
||||
}
|
||||
|
||||
// ---- Dispatch ----
|
||||
@@ -866,6 +926,14 @@ switch (cmd) {
|
||||
await cmdList(rest);
|
||||
break;
|
||||
|
||||
case "list-meta":
|
||||
await cmdListMeta(rest);
|
||||
break;
|
||||
|
||||
case "list-schema":
|
||||
await cmdListSchema(rest);
|
||||
break;
|
||||
|
||||
case "var": {
|
||||
const [sub, ...subRest] = rest;
|
||||
switch (sub) {
|
||||
|
||||
+203
-243
@@ -1,245 +1,5 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
|
||||
{
|
||||
"type": "2NHHHE9NR0FVT",
|
||||
"value": {
|
||||
"payload": {
|
||||
"age": 30,
|
||||
"name": "Alice",
|
||||
},
|
||||
"type": "3K9ETAPGFPE32",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
|
||||
{
|
||||
"type": "BZ3TV0PTATA52",
|
||||
"value": "ok",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "18HZGJY02WN1K",
|
||||
"value": []
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "9BVX9SHACFC5J",
|
||||
"value": [
|
||||
"A0QKG4ERMXSFG"
|
||||
]
|
||||
}"
|
||||
`;
|
||||
|
||||
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": "EMX6HW4CECBGK",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {},
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
{
|
||||
"type": "F68GWJAT9H6HP",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {},
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
{
|
||||
"type": "A4TV1FVBBNG5M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {},
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
{
|
||||
"type": "A4TV1FVBBNG5M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {},
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
|
||||
{
|
||||
"type": "EMX6HW4CECBGK",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {},
|
||||
"value": "FNDM9C6P23238",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
{
|
||||
"type": "92JS8RXGKDKC2",
|
||||
"value": {
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
|
||||
{
|
||||
"type": "A4TV1FVBBNG5M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
|
||||
{
|
||||
"type": "A4TV1FVBBNG5M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
{
|
||||
"type": "92JS8RXGKDKC2",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||
{
|
||||
"type": "00YH4RTNVWTRB",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "3K9ETAPGFPE32",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "A0QKG4ERMXSFG",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=3K9ETAPGFPE32"`;
|
||||
|
||||
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
|
||||
{
|
||||
"type": "DTNQZ90QQHA6D",
|
||||
"value": {
|
||||
"contentHash": "DBBPX5JJ74SWZ",
|
||||
"schemaHash": "3K9ETAPGFPE32",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
|
||||
{
|
||||
"type": "8KMGVC6TG12TS",
|
||||
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
|
||||
{
|
||||
"type": "6RZCT3XX4J8BC",
|
||||
"value": [
|
||||
{
|
||||
"contentHash": "DBBPX5JJ74SWZ",
|
||||
"schemaHash": "3K9ETAPGFPE32",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
|
||||
{
|
||||
"type": "DBHJH385HGVTM",
|
||||
"value": {
|
||||
"deleted": true,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 3K9ETAPGFPE32"`;
|
||||
|
||||
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>...]"`;
|
||||
@@ -254,16 +14,18 @@ command's @output/* schema (shown in parentheses); pipe any envelope into
|
||||
\`render -p\` to render its value (cas_ref hashes are expanded).
|
||||
|
||||
Commands:
|
||||
put <type-hash> <file.json> Store node, print envelope (value=hash) (@output/put)
|
||||
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
|
||||
get <hash> Print node as envelope (@output/get)
|
||||
has <hash> Print envelope (value=boolean) (@output/has)
|
||||
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
|
||||
refs <hash> List direct cas_ref edges (@output/refs)
|
||||
walk <hash> [--format tree] Recursive traversal (@output/walk)
|
||||
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
|
||||
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
|
||||
render <hash> [options] Render node as text with resolution decay (raw output)
|
||||
render --pipe/-p [options] Render { type, value } from stdin (raw output)
|
||||
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
|
||||
list-meta List meta-schema hashes (value=string[]) (@output/list-meta)
|
||||
list-schema List all schema hashes (value=string[]) (@output/list-schema)
|
||||
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
|
||||
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
|
||||
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
|
||||
@@ -285,5 +47,203 @@ Flags:
|
||||
--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"
|
||||
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)"
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||
{
|
||||
"type": "5KVHSESSX7N96",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
{
|
||||
"type": "7D4R2MJ1EYF08",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
{
|
||||
"type": "E8158M25YNR9M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
{
|
||||
"type": "E8158M25YNR9M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
|
||||
{
|
||||
"type": "5KVHSESSX7N96",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {},
|
||||
"value": "AE80669JYG4K2",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
{
|
||||
"type": "2WX4XRYSACRWR",
|
||||
"value": {
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
|
||||
{
|
||||
"type": "E8158M25YNR9M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
|
||||
{
|
||||
"type": "E8158M25YNR9M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
{
|
||||
"type": "2WX4XRYSACRWR",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||
{
|
||||
"type": "F89626QEK09YY",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=8WAZV39SD724T"`;
|
||||
|
||||
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
|
||||
{
|
||||
"type": "A8RGY9B1JSCDJ",
|
||||
"value": {
|
||||
"contentHash": "0359SHMP7VBFD",
|
||||
"schemaHash": "8WAZV39SD724T",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
|
||||
{
|
||||
"type": "64FP3TWKK69YH",
|
||||
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
|
||||
{
|
||||
"type": "BE20H482GBNM3",
|
||||
"value": [
|
||||
{
|
||||
"contentHash": "0359SHMP7VBFD",
|
||||
"schemaHash": "8WAZV39SD724T",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
|
||||
{
|
||||
"type": "0FXB2GS8Y9R1R",
|
||||
"value": {
|
||||
"deleted": true,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 8WAZV39SD724T"`;
|
||||
@@ -0,0 +1,14 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
|
||||
{
|
||||
"type": "CE40D09H75NFZ",
|
||||
"value": {
|
||||
"payload": {
|
||||
"age": 30,
|
||||
"name": "Alice",
|
||||
},
|
||||
"type": "8WAZV39SD724T",
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,5 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
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!"`;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
|
||||
@@ -0,0 +1,24 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
|
||||
{
|
||||
"type": "BZ31JDDWX2AWH",
|
||||
"value": "ok",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "DG8SAB75PV9P7",
|
||||
"value": []
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "EVHG7Q7FK83H0",
|
||||
"value": [
|
||||
"6KZ930XYK2MHB"
|
||||
]
|
||||
}"
|
||||
`;
|
||||
@@ -0,0 +1,187 @@
|
||||
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";
|
||||
|
||||
// ---- @ 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, "../src/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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract the `value` field from a { type, value } envelope JSON string. */
|
||||
function envValue(json: string): unknown {
|
||||
return (JSON.parse(json.trim()) as { value: unknown }).value;
|
||||
}
|
||||
|
||||
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 an envelope whose value is a valid hash (13 chars)
|
||||
expect(envValue(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(envValue(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(envValue(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);
|
||||
});
|
||||
|
||||
test("ucas put @schema with nested type constraints should succeed", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const schemaFile = join(testDir, "constrained-schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1, maxLength: 50 },
|
||||
age: { type: "number", minimum: 0, maximum: 150 },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
}),
|
||||
);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||
"put",
|
||||
"@schema",
|
||||
schemaFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
});
|
||||
|
||||
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(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { envValue, stripVolatile } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||
|
||||
// --- ucas command alias tests (from cli.test.ts) ---
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- e2e Phase 7: Edge Cases ---
|
||||
|
||||
describe("Phase 7: Edge Cases", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||
const { putSchema } = await import("@uncaged/json-cas");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||
nodeHash = envValue(stdout) as string;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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 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.6 --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");
|
||||
});
|
||||
});
|
||||
|
||||
// --- e2e Phase 3: Variable System (edge cases from e2e.test.ts) ---
|
||||
|
||||
describe("Phase 3: Variable System", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||
const { putSchema } = await import("@uncaged/json-cas");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||
nodeHash = envValue(stdout) as string;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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: node2Out } = await runCli(["put", typeHash, node2File]);
|
||||
const node2Hash = envValue(node2Out) as string;
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"set",
|
||||
"myapp/config",
|
||||
node2Hash,
|
||||
]);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// --- e2e Phase 4: Template System ---
|
||||
|
||||
describe("Phase 4: Template System", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||
const { putSchema } = await import("@uncaged/json-cas");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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(stripVolatile(stdout)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("4.2 template get returns template text", async () => {
|
||||
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toBe(
|
||||
"Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
);
|
||||
expect(stripVolatile(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(stripVolatile(stdout)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("4.4 template delete removes template", async () => {
|
||||
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { envValue } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||
const { putSchema } = await import("@uncaged/json-cas");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||
nodeHash = envValue(stdout) as string;
|
||||
|
||||
// Set a var referencing the node so it survives GC
|
||||
await runCli(["var", "set", "gc-test/ref", nodeHash]);
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// ---- Phase 6: GC ----
|
||||
|
||||
describe("Phase 6: GC", () => {
|
||||
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 = envValue(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 | render -p renders the gc stats", async () => {
|
||||
const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]);
|
||||
expect(gcExit).toBe(0);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--store",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"render",
|
||||
"--pipe",
|
||||
],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
proc.stdin.write(gcOut);
|
||||
proc.stdin.end();
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
expect(exitCode).toBe(0);
|
||||
// gc value is an object { total, reachable, collected, scanned }
|
||||
expect(stdout).toContain("total:");
|
||||
});
|
||||
|
||||
test("6.3 gc preserves node referenced by a var", async () => {
|
||||
const { exitCode } = await runCli(["gc"]);
|
||||
expect(exitCode).toBe(0);
|
||||
const { stdout } = await runCli(["has", nodeHash]);
|
||||
expect(envValue(stdout)).toBe(true);
|
||||
});
|
||||
|
||||
test("6.4 gc reclaims orphan node", async () => {
|
||||
const orphanFile = join(tmpStore, "orphan.json");
|
||||
writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 }));
|
||||
const { stdout: orphanOut } = await runCli(["put", typeHash, orphanFile]);
|
||||
const orphanHash = envValue(orphanOut) as string;
|
||||
|
||||
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
|
||||
expect(envValue(beforeGc)).toBe(true);
|
||||
|
||||
await runCli(["gc"]);
|
||||
const { stdout: afterGc } = await runCli(["has", orphanHash]);
|
||||
expect(envValue(afterGc)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema } from "@uncaged/json-cas";
|
||||
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
export {
|
||||
join,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
resolve,
|
||||
rmSync,
|
||||
tmpdir,
|
||||
writeFileSync,
|
||||
};
|
||||
|
||||
export const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
export const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||
|
||||
/** Extract the `value` field from a { type, value } envelope JSON string. */
|
||||
export function envValue(json: string): unknown {
|
||||
return (JSON.parse(json.trim()) as { value: unknown }).value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a schema directly via the library (CLI schema put was removed).
|
||||
* Returns the type hash.
|
||||
*/
|
||||
export async function putSchemaFile(
|
||||
storePath: string,
|
||||
schemaFilePath: string,
|
||||
): Promise<string> {
|
||||
const store = await openFsStore(storePath);
|
||||
const schema = JSON.parse(
|
||||
readFileSync(schemaFilePath, "utf-8"),
|
||||
) as JSONSchema;
|
||||
const hash = await putSchema(store, schema);
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run CLI command. Accepts either a string[] or ...string[] (rest args).
|
||||
* If first arg is an array, uses that as args. Otherwise treats all args as the command.
|
||||
*/
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export async function runCliWithStdin(
|
||||
args: string[],
|
||||
storePath: string,
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const finalArgs = ["bun", entrypoint, "--store", storePath, ...args];
|
||||
const proc = Bun.spawn(finalArgs, {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
});
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON and strip volatile fields (timestamp, created, updated)
|
||||
* so snapshots are stable across runs.
|
||||
*/
|
||||
export 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));
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { BOOTSTRAP_STORE } from "@uncaged/json-cas";
|
||||
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
|
||||
import { envValue, runCli } from "./helpers.js";
|
||||
|
||||
let storePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
storePath = mkdtempSync(join(tmpdir(), "json-cas-list-meta-schema-"));
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(storePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("list-meta CLI command", () => {
|
||||
test("E1. list-meta on bootstrapped store contains exactly the meta-schema hash", async () => {
|
||||
// First, get @schema hash by calling has on it (also triggers bootstrap)
|
||||
const { stdout: hashOut, exitCode: hashCode } = await runCli(
|
||||
["hash", "@schema", "--pipe"],
|
||||
storePath,
|
||||
);
|
||||
// ensure bootstrap by running a no-op command:
|
||||
void hashOut;
|
||||
void hashCode;
|
||||
|
||||
// Bootstrap fully via 'list --type @schema'
|
||||
const { stdout: schemaListOut } = await runCli(
|
||||
["list", "--type", "@schema"],
|
||||
storePath,
|
||||
);
|
||||
const schemaList = envValue(schemaListOut) as string[];
|
||||
expect(Array.isArray(schemaList)).toBe(true);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(["list-meta"], storePath);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
|
||||
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
|
||||
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
expect(Array.isArray(parsed.value)).toBe(true);
|
||||
expect(parsed.value).toHaveLength(1);
|
||||
expect(parsed.value[0]).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("E1. --json flag yields compact JSON", async () => {
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["--json", "list-meta"],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
// compact = no newlines/spaces between fields
|
||||
expect(stdout.trim()).not.toContain("\n ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("list-schema CLI command", () => {
|
||||
test("E2. list-schema on bootstrapped store includes meta-schema and built-ins", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
["list-schema"],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
|
||||
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
|
||||
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
expect(Array.isArray(parsed.value)).toBe(true);
|
||||
// meta + 5 primitive + 20 output = 26
|
||||
expect(parsed.value.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage help", () => {
|
||||
test("E3. printing usage includes list-meta and list-schema lines", async () => {
|
||||
const { stdout, exitCode } = await runCli([], storePath);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("list-meta");
|
||||
expect(stdout).toContain("list-schema");
|
||||
});
|
||||
});
|
||||
|
||||
describe("F1. output schemas registered", () => {
|
||||
test("@output/list-meta and @output/list-schema schemas exist", async () => {
|
||||
const { stdout, exitCode } = await runCli(["list-meta"], storePath);
|
||||
expect(exitCode).toBe(0);
|
||||
const parsed = JSON.parse(stdout) as { type: string };
|
||||
// type hash references the @output/list-meta schema, must be retrievable
|
||||
const { stdout: getOut, exitCode: getCode } = await runCli(
|
||||
["get", parsed.type],
|
||||
storePath,
|
||||
);
|
||||
expect(getCode).toBe(0);
|
||||
const node = envValue(getOut) as {
|
||||
payload: { title?: string };
|
||||
};
|
||||
expect(node.payload.title).toBe("ucas list-meta result");
|
||||
|
||||
const { stdout: schemaOut } = await runCli(["list-schema"], storePath);
|
||||
const schemaParsed = JSON.parse(schemaOut) as { type: string };
|
||||
const { stdout: getSchOut, exitCode: getSchCode } = await runCli(
|
||||
["get", schemaParsed.type],
|
||||
storePath,
|
||||
);
|
||||
expect(getSchCode).toBe(0);
|
||||
const schNode = envValue(getSchOut) as {
|
||||
payload: { title?: string };
|
||||
};
|
||||
expect(schNode.payload.title).toBe("ucas list-schema result");
|
||||
});
|
||||
});
|
||||
|
||||
describe("E4. list-schema vs list --type with multiple meta-schema versions", () => {
|
||||
test("list-schema includes schemas typed by older meta-schemas; list --type @schema does not", async () => {
|
||||
// Set up two distinct meta-schemas via the library
|
||||
const store = await openFsStore(storePath);
|
||||
const m1 = await store[BOOTSTRAP_STORE]({
|
||||
type: "object",
|
||||
title: "meta-v1",
|
||||
});
|
||||
const m2 = await store[BOOTSTRAP_STORE]({
|
||||
type: "object",
|
||||
title: "meta-v2",
|
||||
});
|
||||
// schema typed by older meta M1
|
||||
const sM1 = await store.put(m1, { type: "string" });
|
||||
expect(m1).not.toBe(m2);
|
||||
|
||||
// CLI: list-schema must include sM1
|
||||
const { stdout: lsOut, exitCode: lsCode } = await runCli(
|
||||
["list-schema"],
|
||||
storePath,
|
||||
);
|
||||
expect(lsCode).toBe(0);
|
||||
const lsValue = envValue(lsOut) as string[];
|
||||
expect(lsValue).toContain(sM1);
|
||||
expect(lsValue).toContain(m1);
|
||||
expect(lsValue).toContain(m2);
|
||||
|
||||
// CLI: list --type <M2 hash> must NOT include sM1
|
||||
const { stdout: ltOut, exitCode: ltCode } = await runCli(
|
||||
["list", "--type", m2],
|
||||
storePath,
|
||||
);
|
||||
expect(ltCode).toBe(0);
|
||||
const ltValue = envValue(ltOut) as string[];
|
||||
expect(ltValue).not.toContain(sM1);
|
||||
|
||||
// list-schema.value.length > list --type <newest>.value.length
|
||||
expect(lsValue.length).toBeGreaterThan(ltValue.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { envValue, stripVolatile } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||
const { putSchema } = await import("@uncaged/json-cas");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||
nodeHash = envValue(stdout) as string;
|
||||
|
||||
// Set up template for render tests
|
||||
const tmplFile = join(tmpStore, "render-template.liquid");
|
||||
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
|
||||
await runCli(["template", "set", typeHash, tmplFile]);
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
async function runCliWithStdin(
|
||||
args: string[],
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
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 };
|
||||
}
|
||||
|
||||
// ---- Phase 8: Pipe Composition ----
|
||||
|
||||
describe("Phase 8: Pipe Composition", () => {
|
||||
test("8.1 put | render -p expands the stored hash to its content", async () => {
|
||||
const nodeFile = join(tmpStore, "pipe-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Bob", age: 42 }));
|
||||
|
||||
const { stdout: putOut, exitCode: putExit } = await runCli([
|
||||
"put",
|
||||
typeHash,
|
||||
nodeFile,
|
||||
]);
|
||||
expect(putExit).toBe(0);
|
||||
|
||||
// The put envelope value is a cas_ref hash; render -p dereferences it and
|
||||
// renders the stored node's payload.
|
||||
const { stdout, exitCode } = await runCliWithStdin(
|
||||
["render", "--pipe"],
|
||||
putOut,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Bob");
|
||||
});
|
||||
|
||||
test("8.3 list --type @schema emits a parseable envelope of hashes", async () => {
|
||||
const { stdout, exitCode } = await runCli(["list", "--type", "@schema"]);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Downstream consumers (jq, etc.) read the `value` array of hashes.
|
||||
const value = envValue(stdout) as string[];
|
||||
expect(Array.isArray(value)).toBe(true);
|
||||
for (const hash of value) {
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("8.4 list --type @schema | render -p expands the schema list", async () => {
|
||||
const { stdout: listOut } = await runCli(["list", "--type", "@schema"]);
|
||||
// list result items are cas_ref hashes; render -p dereferences each one
|
||||
// and renders the schema contents.
|
||||
const { stdout, exitCode } = await runCliWithStdin(
|
||||
["render", "--pipe"],
|
||||
listOut,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("8.5 render <hash> uses a registered template", async () => {
|
||||
// Register a template for the schema, then render a fresh node by hash.
|
||||
const tmplFile = join(tmpStore, "pipe-render.liquid");
|
||||
writeFileSync(tmplFile, "Person: {{ payload.name }} ({{ payload.age }})");
|
||||
const { exitCode: setExit } = await runCli([
|
||||
"template",
|
||||
"set",
|
||||
typeHash,
|
||||
tmplFile,
|
||||
]);
|
||||
expect(setExit).toBe(0);
|
||||
|
||||
const nodeFile = join(tmpStore, "pipe-render-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Carol", age: 25 }));
|
||||
const { stdout: putOut } = await runCli(["put", typeHash, nodeFile]);
|
||||
const freshHash = envValue(putOut) as string;
|
||||
|
||||
const { stdout, exitCode } = await runCli(["render", freshHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toBe("Person: Carol (25)");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Phase 9: Put/Hash Pipe Input ----
|
||||
|
||||
describe("Phase 9: Put/Hash Pipe Input", () => {
|
||||
test("9.1 put -p reads JSON from stdin and stores node", async () => {
|
||||
const payload = JSON.stringify({ name: "PipeAlice", age: 99 });
|
||||
const { stdout, exitCode } = await runCliWithStdin(
|
||||
["put", typeHash, "-p"],
|
||||
payload,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const hash = envValue(stdout);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
|
||||
// Verify stored correctly
|
||||
const { stdout: getOut } = await runCli(["get", hash as string]);
|
||||
expect(getOut).toContain("PipeAlice");
|
||||
});
|
||||
|
||||
test("9.2 hash -p reads JSON from stdin and computes hash without storing", async () => {
|
||||
const payload = JSON.stringify({ name: "PipeBob", age: 55 });
|
||||
const { stdout, exitCode } = await runCliWithStdin(
|
||||
["hash", typeHash, "-p"],
|
||||
payload,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const hash = envValue(stdout);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
|
||||
// Should NOT be stored
|
||||
const { exitCode: hasExit, stdout: hasOut } = await runCli([
|
||||
"has",
|
||||
hash as string,
|
||||
]);
|
||||
expect(hasExit).toBe(0);
|
||||
expect(envValue(hasOut)).toBe(false);
|
||||
});
|
||||
|
||||
test("9.3 put -p with file arg errors", async () => {
|
||||
const { stderr, exitCode } = await runCliWithStdin(
|
||||
["put", typeHash, "some-file.json", "-p"],
|
||||
"{}",
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Cannot use --pipe/-p with a file argument");
|
||||
});
|
||||
|
||||
test("9.4 put -p with empty stdin errors", async () => {
|
||||
const { stderr, exitCode } = await runCliWithStdin(
|
||||
["put", typeHash, "-p"],
|
||||
"",
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("No input on stdin");
|
||||
});
|
||||
|
||||
test("9.5 put -p with invalid JSON errors", async () => {
|
||||
const { stderr, exitCode } = await runCliWithStdin(
|
||||
["put", typeHash, "-p"],
|
||||
"not json",
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Invalid JSON on stdin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { envValue, stripVolatile } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||
const { putSchema } = await import("@uncaged/json-cas");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||
nodeHash = envValue(stdout) as string;
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
describe("Phase 1: CAS Core", () => {
|
||||
test("1.1 init + put with @object bootstraps store", async () => {
|
||||
expect(typeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("1.5 put returns node hash", async () => {
|
||||
expect(nodeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
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(envValue(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(envValue(stdout)).toBe(false);
|
||||
});
|
||||
|
||||
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(envValue(stdout)).toBe(nodeHash);
|
||||
});
|
||||
|
||||
test("1.13 list --type returns nodes of that type", async () => {
|
||||
const { stdout, exitCode } = await runCli(["list", "--type", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toContain(nodeHash);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { bootstrap } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { envValue, putSchemaFile, runCli, runCliWithStdin } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
// --- Standalone render tests from cli.test.ts ---
|
||||
|
||||
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, stderr } = await runCli(
|
||||
["render", "ZZZZZZZZZZZZZ"],
|
||||
tmpStore,
|
||||
);
|
||||
// Missing hash should exit with error
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Node not found");
|
||||
expect(stderr).toContain("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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- e2e Phase 5: Render ---
|
||||
|
||||
describe("Phase 5: Render", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
async function runCliE2e(
|
||||
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 };
|
||||
}
|
||||
|
||||
async function runCliE2eWithStdin(
|
||||
args: string[],
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
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 };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||
const { putSchema } = await import("@uncaged/json-cas");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const { stdout } = await runCliE2e(["put", typeHash, nodeFile]);
|
||||
nodeHash = envValue(stdout) as string;
|
||||
|
||||
// Register template for render tests
|
||||
const tmplFile = join(tmpStore, "render-template.liquid");
|
||||
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
|
||||
await runCliE2e(["template", "set", typeHash, tmplFile]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("5.1 render fills payload variables", async () => {
|
||||
const { stdout, exitCode } = await runCliE2e(["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 runCliE2e([
|
||||
"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 runCliE2e(["render", "ZZZZZZZZZZZZZ"]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Node not found");
|
||||
expect(stderr).toContain("ZZZZZZZZZZZZZ");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Suite 6: CLI Integration with Templates (from cli.test.ts) ---
|
||||
|
||||
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 schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Create node
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice" }));
|
||||
const { stdout: nodeOut } = await runCli(
|
||||
["put", schemaHash.trim(), nodeFile],
|
||||
tmpStore,
|
||||
);
|
||||
const nodeHash = envValue(nodeOut) as string;
|
||||
|
||||
// Create template file (JSON-encoded string)
|
||||
const templateFile = join(tmpStore, "template.json");
|
||||
writeFileSync(templateFile, JSON.stringify("Hello {{ payload.name }}!"));
|
||||
const { stdout: tmplOut } = await runCli(
|
||||
["put", "@string", templateFile],
|
||||
tmpStore,
|
||||
);
|
||||
const tmplHash = envValue(tmplOut) as string;
|
||||
|
||||
// Register template
|
||||
await runCli(
|
||||
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Render with template
|
||||
const { stdout: output, exitCode } = await runCli(
|
||||
["render", nodeHash],
|
||||
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 schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Create child node
|
||||
const childFile = join(tmpStore, "child.json");
|
||||
writeFileSync(childFile, JSON.stringify({ value: "child", child: null }));
|
||||
const { stdout: childOut } = await runCli(
|
||||
["put", schemaHash.trim(), childFile],
|
||||
tmpStore,
|
||||
);
|
||||
const childHash = envValue(childOut) as string;
|
||||
|
||||
// Create parent node
|
||||
const parentFile = join(tmpStore, "parent.json");
|
||||
writeFileSync(
|
||||
parentFile,
|
||||
JSON.stringify({ value: "parent", child: childHash }),
|
||||
);
|
||||
const { stdout: parentOut } = await runCli(
|
||||
["put", schemaHash.trim(), parentFile],
|
||||
tmpStore,
|
||||
);
|
||||
const parentHash = envValue(parentOut) as string;
|
||||
|
||||
// Create template showing resolution (JSON-encoded string)
|
||||
const templateFile = join(tmpStore, "template.json");
|
||||
writeFileSync(
|
||||
templateFile,
|
||||
JSON.stringify("{{ payload.value }}(res={{ resolution }})"),
|
||||
);
|
||||
const { stdout: tmplOut } = await runCli(
|
||||
["put", "@string", templateFile],
|
||||
tmpStore,
|
||||
);
|
||||
const tmplHash = envValue(tmplOut) as string;
|
||||
|
||||
// Register template
|
||||
await runCli(
|
||||
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Render with custom decay
|
||||
const { stdout: output, exitCode } = await runCli(
|
||||
["render", parentHash, "--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 schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Bob" }));
|
||||
const { stdout: nodeOut } = await runCli(
|
||||
["put", schemaHash.trim(), nodeFile],
|
||||
tmpStore,
|
||||
);
|
||||
const nodeHash = envValue(nodeOut) as string;
|
||||
|
||||
// Create template (JSON-encoded string)
|
||||
const templateFile = join(tmpStore, "template.json");
|
||||
writeFileSync(
|
||||
templateFile,
|
||||
JSON.stringify("Greetings {{ payload.name }}!"),
|
||||
);
|
||||
const { stdout: tmplOut } = await runCli(
|
||||
["put", "@string", templateFile],
|
||||
tmpStore,
|
||||
);
|
||||
const tmplHash = envValue(tmplOut) as string;
|
||||
|
||||
await runCli(
|
||||
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
const { stdout: output, exitCode } = await runCli(
|
||||
[
|
||||
"render",
|
||||
nodeHash,
|
||||
"--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 schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" }));
|
||||
const { stdout: nodeOut } = await runCli(
|
||||
["put", schemaHash.trim(), nodeFile],
|
||||
tmpStore,
|
||||
);
|
||||
const nodeHash = envValue(nodeOut) as string;
|
||||
|
||||
// No template registered - should fall back to YAML
|
||||
const { stdout: output, exitCode } = await runCli(
|
||||
["render", nodeHash],
|
||||
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 schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Test" }));
|
||||
const { stdout: nodeOut } = await runCli(
|
||||
["put", schemaHash.trim(), nodeFile],
|
||||
tmpStore,
|
||||
);
|
||||
const nodeHash = envValue(nodeOut) as string;
|
||||
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["render", nodeHash, "--decay", "1.5"],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("decay");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("R8: render with non-existent hash exits with error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["render", "AAAAAAAAAAAAA"],
|
||||
tmpStore,
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Node not found");
|
||||
expect(stderr).toContain("AAAAAAAAAAAAA");
|
||||
expect(stdout).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("R9: render with valid hash exits successfully", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Get @string type hash via bootstrap
|
||||
const store = createFsStore(tmpStore);
|
||||
const types = await bootstrap(store);
|
||||
const stringType = types["@string"];
|
||||
|
||||
// Create and store a simple string node
|
||||
const nodeFile = join(tmpStore, "test.json");
|
||||
writeFileSync(nodeFile, JSON.stringify("hello world"));
|
||||
const { stdout: nodeOut } = await runCli(
|
||||
["put", stringType, nodeFile],
|
||||
tmpStore,
|
||||
);
|
||||
const nodeHash = envValue(nodeOut) as string;
|
||||
|
||||
// Render the valid hash
|
||||
const { exitCode, stdout, stderr } = await runCli(
|
||||
["render", nodeHash],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("hello world");
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout).not.toContain("Error");
|
||||
expect(stdout).not.toContain("Node not found");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("R10: render --pipe with valid envelope succeeds", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Get @string type hash via bootstrap
|
||||
const store = createFsStore(tmpStore);
|
||||
const types = await bootstrap(store);
|
||||
const stringType = types["@string"];
|
||||
|
||||
// Create envelope and pipe to render
|
||||
const envelope = JSON.stringify({ type: stringType, value: "test" });
|
||||
const { exitCode, stdout, stderr } = await runCliWithStdin(
|
||||
["render", "--pipe"],
|
||||
tmpStore,
|
||||
envelope,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("test");
|
||||
expect(stderr).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("R11: render --pipe with invalid type hash still renders", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Use invalid type hash in envelope
|
||||
const envelope = JSON.stringify({
|
||||
type: "ZZZZZZZZZZZZZ",
|
||||
value: "test",
|
||||
});
|
||||
const { exitCode, stdout, stderr } = await runCliWithStdin(
|
||||
["render", "--pipe"],
|
||||
tmpStore,
|
||||
envelope,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("test");
|
||||
expect(stderr).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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.
|
||||
@@ -0,0 +1,659 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { envValue, putSchemaFile, runCli } from "./helpers";
|
||||
|
||||
// ---- Issue #50: Schema Validation in put Command ----
|
||||
|
||||
describe("Issue #50: Schema Validation in put", () => {
|
||||
describe("Test Group 1: Valid Data (Regression Tests)", () => {
|
||||
test("T1.1: Valid data matching schema should be accepted", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Create schema with required name property
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Create valid payload
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
|
||||
// Verify node was stored
|
||||
const hash = envValue(stdout) as string;
|
||||
const { exitCode: hasExitCode } = await runCli(["has", hash], tmpStore);
|
||||
expect(hasExitCode).toBe(0);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T1.2: Valid data with optional properties should be accepted", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Schema with optional property
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload with only required properties
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
|
||||
|
||||
const { exitCode, stdout } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T1.3: Valid data with nested objects should be accepted", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Schema with nested structure
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
address: {
|
||||
type: "object",
|
||||
properties: {
|
||||
street: { type: "string" },
|
||||
city: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload with nested structure
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(
|
||||
payloadFile,
|
||||
JSON.stringify({
|
||||
name: "test",
|
||||
address: { street: "123 Main", city: "NYC" },
|
||||
}),
|
||||
);
|
||||
|
||||
const { exitCode, stdout } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T1.4: Valid data using @ alias for type-hash should be accepted", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify("hello world"));
|
||||
|
||||
const { exitCode, stdout } = await runCli(
|
||||
["put", "@string", payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 2: Type Mismatches (New Validation)", () => {
|
||||
test("T2.1: Wrong property type should be rejected", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Schema with name as string
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload with name as number
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||
|
||||
const { exitCode, stdout, stderr } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr).toContain("Validation failed");
|
||||
expect(stderr).toContain(schemaHash.trim());
|
||||
expect(stderr).toContain(payloadFile);
|
||||
|
||||
// Verify no node was stored
|
||||
const { stdout: hasOutput } = await runCli(
|
||||
["has", "0000000000000"],
|
||||
tmpStore,
|
||||
);
|
||||
expect(envValue(hasOutput)).toBe(false);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T2.2: Missing required property should be rejected", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Schema with required name
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Empty payload
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({}));
|
||||
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Validation failed");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T2.3: Additional properties when disallowed should be rejected", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Schema with additionalProperties: false
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload with extra property
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(
|
||||
payloadFile,
|
||||
JSON.stringify({ name: "test", extra: "not allowed" }),
|
||||
);
|
||||
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Validation failed");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T2.4: Wrong root type should be rejected", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Schema expecting array
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload is an object
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({}));
|
||||
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Validation failed");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T2.5: Nested type mismatch should be rejected", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Schema with nested object
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
age: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload with wrong nested type
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(
|
||||
payloadFile,
|
||||
JSON.stringify({ user: { age: "not a number" } }),
|
||||
);
|
||||
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Validation failed");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 3: Schema Errors (Edge Cases)", () => {
|
||||
test("T3.1: Non-existent type-hash should fail gracefully", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
|
||||
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["put", "ZZZZZZZZZZZZZ", payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Schema not found");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T3.3: Invalid @ alias should fail before validation", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({}));
|
||||
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["put", "@nonexistent", payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Schema not found");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 4: Integration with Existing Features", () => {
|
||||
test("T4.1: Hash command should not validate (dry-run consistency)", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Create schema
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Invalid payload
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||
|
||||
// Hash command should succeed even with invalid data
|
||||
const { exitCode, stdout } = await runCli(
|
||||
["hash", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T4.2: Validation respects cas_ref format in schemas", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Schema with cas_ref format
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Valid cas_ref
|
||||
const validFile = join(tmpStore, "valid.json");
|
||||
writeFileSync(validFile, JSON.stringify({ ref: "0000000000000" }));
|
||||
|
||||
const { exitCode: validExitCode } = await runCli(
|
||||
["put", schemaHash.trim(), validFile],
|
||||
tmpStore,
|
||||
);
|
||||
expect(validExitCode).toBe(0);
|
||||
|
||||
// Invalid cas_ref (wrong length)
|
||||
const invalidFile = join(tmpStore, "invalid.json");
|
||||
writeFileSync(invalidFile, JSON.stringify({ ref: "short" }));
|
||||
|
||||
const { exitCode: invalidExitCode, stderr } = await runCli(
|
||||
["put", schemaHash.trim(), invalidFile],
|
||||
tmpStore,
|
||||
);
|
||||
expect(invalidExitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Validation failed");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T4.3: Schema self-validation still works", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Valid schema
|
||||
const schemaFile = join(tmpStore, "schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
}),
|
||||
);
|
||||
|
||||
const hash = await putSchemaFile(tmpStore, schemaFile);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Group 5: Error Message Quality", () => {
|
||||
test("T5.1: Error message should be helpful", 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" } },
|
||||
required: ["name"],
|
||||
}),
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||
|
||||
const { stderr } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(stderr).toContain("Validation failed");
|
||||
expect(stderr).toContain(schemaHash.trim());
|
||||
expect(stderr).toContain(payloadFile);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("T5.2: Error should go to stderr, not stdout", 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 schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||
|
||||
const { stdout, stderr } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// e2e Phase 2 tests
|
||||
describe("Phase 2: Schema Validation", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||
const { putSchema } = await import("@uncaged/json-cas");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--store",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"put",
|
||||
typeHash,
|
||||
nodeFile,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
nodeHash = envValue(stdout) as string;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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 proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--store",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"put",
|
||||
typeHash,
|
||||
badFile,
|
||||
],
|
||||
{ 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();
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr).toContain("Validation failed");
|
||||
expect(stderr).toContain(typeHash);
|
||||
});
|
||||
|
||||
test("2.3 put against non-existent schema hash fails", async () => {
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--store",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"put",
|
||||
"AAAAAAAAAAAAA",
|
||||
nodeFile,
|
||||
],
|
||||
{ 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).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -21,7 +21,7 @@ beforeEach(() => {
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
cliPath = join(import.meta.dir, "index.ts");
|
||||
cliPath = join(import.meta.dir, "../src/index.ts");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
+1
-1
@@ -21,7 +21,7 @@ beforeEach(() => {
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
cliPath = join(import.meta.dir, "index.ts");
|
||||
cliPath = join(import.meta.dir, "../src/index.ts");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { envValue, stripVolatile } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||
const { putSchema } = await import("@uncaged/json-cas");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||
nodeHash = envValue(stdout) as string;
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
describe("Phase 1: CAS Core", () => {
|
||||
test("1.9 verify returns ok for valid node", async () => {
|
||||
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Phase 2: Schema Validation", () => {
|
||||
test("2.2 verify on valid node returns ok (hash + schema)", async () => {
|
||||
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -10,6 +11,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasNode } from "@uncaged/json-cas";
|
||||
import {
|
||||
BOOTSTRAP_STORE,
|
||||
bootstrap,
|
||||
computeHash,
|
||||
computeSelfHash,
|
||||
@@ -65,7 +67,7 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(26);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -429,3 +431,130 @@ describe("openStore – async with auto-bootstrap", () => {
|
||||
expect(store2.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// listMeta and listSchemas (FS persistence)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createFsStore – listMeta and listSchemas", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "json-cas-fs-meta-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("C1. _index/_meta exists and contains hash after self-referencing put", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
|
||||
const metaPath = join(dir, "_index", "_meta");
|
||||
expect(existsSync(metaPath)).toBe(true);
|
||||
const content = readFileSync(metaPath, "utf8");
|
||||
expect(content).toBe(`${hash}\n`);
|
||||
});
|
||||
|
||||
test("C2. multiple meta puts append, no duplicates", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const h1 = await store[BOOTSTRAP_STORE]({ type: "object", v: 1 });
|
||||
const h2 = await store[BOOTSTRAP_STORE]({ type: "object", v: 2 });
|
||||
// re-put first (idempotent)
|
||||
await store[BOOTSTRAP_STORE]({ type: "object", v: 1 });
|
||||
|
||||
const content = readFileSync(join(dir, "_index", "_meta"), "utf8");
|
||||
const lines = content.split("\n").filter((l) => l.length > 0);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines).toContain(h1);
|
||||
expect(lines).toContain(h2);
|
||||
});
|
||||
|
||||
test("C3. reload from disk preserves listMeta", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "a" });
|
||||
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "b" });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const meta = store2.listMeta();
|
||||
expect(meta).toContain(h1);
|
||||
expect(meta).toContain(h2);
|
||||
expect(meta).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("C4. migrates from existing nodes when _meta is missing", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "mig" });
|
||||
const t = await computeSelfHash({ name: "regular" });
|
||||
await store1.put(h1, { type: "string" });
|
||||
|
||||
// Remove _index/_meta but keep _index/* and .bin files
|
||||
const metaPath = join(dir, "_index", "_meta");
|
||||
rmSync(metaPath, { force: true });
|
||||
expect(existsSync(metaPath)).toBe(false);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listMeta()).toContain(h1);
|
||||
expect(existsSync(metaPath)).toBe(true);
|
||||
const content = readFileSync(metaPath, "utf8");
|
||||
expect(content).toContain(h1);
|
||||
|
||||
// unrelated type hash not in meta
|
||||
expect(store2.listMeta()).not.toContain(t);
|
||||
});
|
||||
|
||||
test("C5. existing _meta is not overwritten", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "keep" });
|
||||
|
||||
const metaPath = join(dir, "_index", "_meta");
|
||||
const before = readFileSync(metaPath, "utf8");
|
||||
|
||||
// Reopen and confirm content unchanged
|
||||
const _store2 = createFsStore(dir);
|
||||
const after = readFileSync(metaPath, "utf8");
|
||||
expect(after).toBe(before);
|
||||
expect(after).toContain(h1);
|
||||
});
|
||||
|
||||
test("C6. listSchemas returns union of typeIndex[m] for m in metaSet", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const s1 = await store.put(m, { type: "string" });
|
||||
const s2 = await store.put(m, { type: "number" });
|
||||
const s3 = await store.put(m, { type: "array" });
|
||||
|
||||
const schemas = store.listSchemas();
|
||||
expect(schemas).toHaveLength(4);
|
||||
expect(schemas).toContain(m);
|
||||
expect(schemas).toContain(s1);
|
||||
expect(schemas).toContain(s2);
|
||||
expect(schemas).toContain(s3);
|
||||
});
|
||||
|
||||
test("C7. delete persists removal from _meta", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: 1 });
|
||||
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: 2 });
|
||||
|
||||
store1.delete(h1);
|
||||
|
||||
const metaPath = join(dir, "_index", "_meta");
|
||||
const content = readFileSync(metaPath, "utf8");
|
||||
expect(content).not.toContain(h1);
|
||||
expect(content).toContain(h2);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listMeta()).not.toContain(h1);
|
||||
expect(store2.listMeta()).toContain(h2);
|
||||
});
|
||||
|
||||
test("C8. fresh store with no self-ref puts has empty listMeta", () => {
|
||||
const store = createFsStore(dir);
|
||||
expect(store.listMeta()).toEqual([]);
|
||||
// _meta may be absent; that's fine
|
||||
const metaPath = join(dir, "_index", "_meta");
|
||||
if (existsSync(metaPath)) {
|
||||
const content = readFileSync(metaPath, "utf8");
|
||||
expect(content).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { decode } from "cborg";
|
||||
|
||||
const INDEX_DIR = "_index";
|
||||
const META_FILE = "_meta";
|
||||
|
||||
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
|
||||
let entries: string[];
|
||||
@@ -100,6 +101,61 @@ function loadOrMigrateTypeIndex(
|
||||
return loadTypeIndex(indexDir);
|
||||
}
|
||||
|
||||
function loadOrMigrateMetaSet(
|
||||
dir: string,
|
||||
data: Map<Hash, CasNode>,
|
||||
): Set<Hash> {
|
||||
const indexDir = join(dir, INDEX_DIR);
|
||||
const metaPath = join(indexDir, META_FILE);
|
||||
if (existsSync(metaPath)) {
|
||||
try {
|
||||
const content = readFileSync(metaPath, "utf8");
|
||||
return new Set(parseIndexFile(content));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
// Migration: scan loaded nodes for self-referencing nodes (type === hash)
|
||||
const metaSet = new Set<Hash>();
|
||||
for (const [hash, node] of data) {
|
||||
if (node.type === hash) {
|
||||
metaSet.add(hash);
|
||||
}
|
||||
}
|
||||
if (metaSet.size > 0) {
|
||||
mkdirSync(indexDir, { recursive: true });
|
||||
const body = `${[...metaSet].join("\n")}\n`;
|
||||
writeFileSync(metaPath, body, "utf8");
|
||||
}
|
||||
return metaSet;
|
||||
}
|
||||
|
||||
function appendToMetaSet(
|
||||
indexDir: string,
|
||||
metaSet: Set<Hash>,
|
||||
hash: Hash,
|
||||
): void {
|
||||
if (metaSet.has(hash)) return;
|
||||
metaSet.add(hash);
|
||||
mkdirSync(indexDir, { recursive: true });
|
||||
appendFileSync(join(indexDir, META_FILE), `${hash}\n`, "utf8");
|
||||
}
|
||||
|
||||
function rewriteMetaSet(indexDir: string, metaSet: Set<Hash>): void {
|
||||
const metaPath = join(indexDir, META_FILE);
|
||||
if (metaSet.size === 0) {
|
||||
try {
|
||||
unlinkSync(metaPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
mkdirSync(indexDir, { recursive: true });
|
||||
const body = `${[...metaSet].join("\n")}\n`;
|
||||
writeFileSync(metaPath, body, "utf8");
|
||||
}
|
||||
|
||||
function appendToTypeIndex(
|
||||
indexDir: string,
|
||||
typeIndex: Map<Hash, Hash[]>,
|
||||
@@ -118,6 +174,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
loadDir(dir, data);
|
||||
const indexDir = join(dir, INDEX_DIR);
|
||||
const typeIndex = loadOrMigrateTypeIndex(dir, data);
|
||||
const metaSet = loadOrMigrateMetaSet(dir, data);
|
||||
|
||||
async function putSelfReferencing(payload: unknown): Promise<Hash> {
|
||||
const hash = await computeSelfHash(payload);
|
||||
@@ -136,6 +193,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
|
||||
appendToTypeIndex(indexDir, typeIndex, hash, hash);
|
||||
}
|
||||
appendToMetaSet(indexDir, metaSet, hash);
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -182,6 +240,22 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
return Array.from(data.keys());
|
||||
},
|
||||
|
||||
listMeta(): Hash[] {
|
||||
return Array.from(metaSet);
|
||||
},
|
||||
|
||||
listSchemas(): Hash[] {
|
||||
const result = new Set<Hash>();
|
||||
for (const meta of metaSet) {
|
||||
result.add(meta);
|
||||
const list = typeIndex.get(meta);
|
||||
if (list) {
|
||||
for (const h of list) result.add(h);
|
||||
}
|
||||
}
|
||||
return Array.from(result);
|
||||
},
|
||||
|
||||
delete(hash: Hash): void {
|
||||
const node = data.get(hash);
|
||||
if (node) {
|
||||
@@ -213,6 +287,11 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
writeFileSync(join(indexDir, node.type), body, "utf8");
|
||||
}
|
||||
}
|
||||
// Remove from meta set if applicable
|
||||
if (metaSet.has(hash)) {
|
||||
metaSet.delete(hash);
|
||||
rewriteMetaSet(indexDir, metaSet);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ const OUTPUT_ALIASES = [
|
||||
"@output/refs",
|
||||
"@output/walk",
|
||||
"@output/list",
|
||||
"@output/list-meta",
|
||||
"@output/list-schema",
|
||||
"@output/var-set",
|
||||
"@output/var-get",
|
||||
"@output/var-delete",
|
||||
@@ -30,11 +32,11 @@ const OUTPUT_ALIASES = [
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bootstrap - Built-in Schemas", () => {
|
||||
test("should return map of 24 built-in schema aliases to hashes", async () => {
|
||||
test("should return map of 26 built-in schema aliases to hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
// Should return object with 6 primitive + 18 output aliases = 24
|
||||
// Should return object with 6 primitive + 20 output aliases = 26
|
||||
expect(builtinSchemas).toHaveProperty("@schema");
|
||||
expect(builtinSchemas).toHaveProperty("@string");
|
||||
expect(builtinSchemas).toHaveProperty("@number");
|
||||
@@ -46,7 +48,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
expect(builtinSchemas).toHaveProperty(alias);
|
||||
}
|
||||
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(24);
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(26);
|
||||
|
||||
// All values should be valid hashes
|
||||
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
||||
@@ -316,3 +318,23 @@ describe("bootstrap - @output/* Schemas", () => {
|
||||
expect(uniqueHashes.size).toBe(OUTPUT_ALIASES.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrap - meta and schemas indexes (D1)", () => {
|
||||
test("listMeta contains the bootstrap meta-schema hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const metaHash = aliases["@schema"];
|
||||
expect(store.listMeta()).toContain(metaHash as string);
|
||||
});
|
||||
|
||||
test("listSchemas contains meta-schema and all built-in schemas", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const schemas = store.listSchemas();
|
||||
|
||||
for (const [, hash] of Object.entries(aliases)) {
|
||||
expect(schemas).toContain(hash);
|
||||
}
|
||||
expect(schemas.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,38 @@ const BOOTSTRAP_PAYLOAD = {
|
||||
minItems: { type: "number" },
|
||||
maxItems: { type: "number" },
|
||||
uniqueItems: { type: "boolean" },
|
||||
// P2 combinators + conditionals
|
||||
allOf: {
|
||||
type: "array",
|
||||
items: { type: "object", additionalProperties: false },
|
||||
},
|
||||
if: { type: "object", additionalProperties: false },
|
||||
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable
|
||||
then: { type: "object", additionalProperties: false },
|
||||
else: { type: "object", additionalProperties: false },
|
||||
patternProperties: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "object", additionalProperties: false },
|
||||
},
|
||||
prefixItems: {
|
||||
type: "array",
|
||||
items: { type: "object", additionalProperties: false },
|
||||
},
|
||||
// P2 leaf constraints
|
||||
multipleOf: { type: "number" },
|
||||
minProperties: { type: "number" },
|
||||
maxProperties: { type: "number" },
|
||||
default: {},
|
||||
// P3 combinators
|
||||
not: { type: "object", additionalProperties: false },
|
||||
contains: { type: "object", additionalProperties: false },
|
||||
propertyNames: { type: "object", additionalProperties: false },
|
||||
// P3 metadata
|
||||
examples: { type: "array" },
|
||||
readOnly: { type: "boolean" },
|
||||
writeOnly: { type: "boolean" },
|
||||
deprecated: { type: "boolean" },
|
||||
$comment: { type: "string" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -140,6 +172,22 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
title: "ucas list result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/list-meta",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
title: "ucas list-meta result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/list-schema",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
title: "ucas list-schema result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-set",
|
||||
{
|
||||
|
||||
@@ -264,7 +264,7 @@ describe("bootstrap", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a map with 24 built-in schema aliases", async () => {
|
||||
test("returns a map with 26 built-in schema aliases", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
@@ -281,7 +281,7 @@ describe("bootstrap", () => {
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(24);
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(26);
|
||||
});
|
||||
|
||||
test("meta-schema node is stored and retrievable", async () => {
|
||||
@@ -318,7 +318,7 @@ describe("bootstrap", () => {
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
// All 24 built-in schemas should be typed by the meta-schema
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
|
||||
// All 26 built-in schemas should be typed by the meta-schema
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(26);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,14 @@ export class MemStore implements BootstrapCapableStore {
|
||||
return this.#inner.listAll();
|
||||
}
|
||||
|
||||
listMeta(): Hash[] {
|
||||
return this.#inner.listMeta();
|
||||
}
|
||||
|
||||
listSchemas(): Hash[] {
|
||||
return this.#inner.listSchemas();
|
||||
}
|
||||
|
||||
delete(hash: Hash): void {
|
||||
this.#inner.delete(hash);
|
||||
}
|
||||
|
||||
@@ -501,4 +501,253 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
const metaNode = store.get(metaHash) as CasNode;
|
||||
expect(metaNode.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
// ── P2 combinators, conditionals, and leaf constraints ──────────────────
|
||||
|
||||
test("accepts schema with allOf", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
allOf: [
|
||||
{ type: "object", properties: { name: { type: "string" } } },
|
||||
{ required: ["name"] },
|
||||
],
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { name: "Alice" });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, {});
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with if/then/else", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string" },
|
||||
value: {},
|
||||
},
|
||||
if: { properties: { kind: { const: "number" } } },
|
||||
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable
|
||||
then: { properties: { value: { type: "number" } } },
|
||||
else: { properties: { value: { type: "string" } } },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("accepts schema with patternProperties", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
patternProperties: {
|
||||
"^x-": { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { "x-custom": "hello" });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts schema with prefixItems (tuple)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "string" }, { type: "number" }],
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("accepts schema with multipleOf", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "number",
|
||||
multipleOf: 5,
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, 15);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, 7);
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with minProperties/maxProperties", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
minProperties: 1,
|
||||
maxProperties: 3,
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { a: 1, b: 2 });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const empty = await store.put(hash, {});
|
||||
expect(validate(store, store.get(empty) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with default value", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
default: "hello",
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("rejects invalid P2 keyword types", async () => {
|
||||
const store = createMemoryStore();
|
||||
await expect(
|
||||
putSchema(store, { allOf: "not-array" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
putSchema(store, { multipleOf: "five" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
putSchema(store, { patternProperties: [1, 2] } as never),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("collectRefs traverses allOf sub-schemas", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
allOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { ref: { type: "string", format: "cas_ref" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "target");
|
||||
const nodeHash = await store.put(schema, { ref: targetHash });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
|
||||
test("collectRefs traverses patternProperties", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
type: "object",
|
||||
patternProperties: {
|
||||
"^ref_": { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "hello");
|
||||
const nodeHash = await store.put(schema, { ref_a: targetHash });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
|
||||
test("collectRefs traverses prefixItems", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "string", format: "cas_ref" }, { type: "number" }],
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "hello");
|
||||
const nodeHash = await store.put(schema, [targetHash, 42]);
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
|
||||
// ── P3 combinators, propertyNames, and metadata ──────────────────────────
|
||||
|
||||
test("accepts schema with not", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
not: { type: "string" },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, 42);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, "hello");
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with contains", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "array",
|
||||
contains: { type: "number", minimum: 10 },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, [1, 2, 15]);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with propertyNames", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
propertyNames: { pattern: "^[a-z]+$" },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { foo: 1, bar: 2 });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, { Foo: 1 });
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with metadata keywords", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
examples: ["hello", "world"],
|
||||
readOnly: true,
|
||||
deprecated: false,
|
||||
$comment: "This is a test schema",
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("rejects invalid P3 keyword types", async () => {
|
||||
const store = createMemoryStore();
|
||||
await expect(
|
||||
putSchema(store, { not: "not-object" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
putSchema(store, { examples: "not-array" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
putSchema(store, { readOnly: "yes" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(putSchema(store, { $comment: 42 } as never)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("collectRefs traverses contains", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
type: "array",
|
||||
contains: { type: "string", format: "cas_ref" },
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "hello");
|
||||
const nodeHash = await store.put(schema, [targetHash, "not-a-ref"]);
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,28 @@ const ALLOWED_SCHEMA_KEYS = new Set([
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
// P2 combinators + conditionals (need collectRefs)
|
||||
"allOf",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"patternProperties",
|
||||
"prefixItems",
|
||||
// P2 leaf constraints
|
||||
"multipleOf",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
"default",
|
||||
// P3 combinators (need collectRefs)
|
||||
"not",
|
||||
"contains",
|
||||
"propertyNames",
|
||||
// P3 metadata (no collectRefs impact)
|
||||
"examples",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"deprecated",
|
||||
"$comment",
|
||||
]);
|
||||
|
||||
const JSON_SCHEMA_TYPES = new Set([
|
||||
@@ -160,6 +182,60 @@ function isValidSchema(value: unknown): boolean {
|
||||
if ("uniqueItems" in schema && typeof schema.uniqueItems !== "boolean")
|
||||
return false;
|
||||
|
||||
// P2 combinators + conditionals — recursive sub-schema checks
|
||||
if ("allOf" in schema) {
|
||||
if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) return false;
|
||||
for (const entry of schema.allOf) {
|
||||
if (!isValidSchema(entry)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("if" in schema && !isValidSchema(schema.if)) return false;
|
||||
if ("then" in schema && !isValidSchema(schema.then)) return false;
|
||||
if ("else" in schema && !isValidSchema(schema.else)) return false;
|
||||
|
||||
if ("patternProperties" in schema) {
|
||||
const pp = schema.patternProperties;
|
||||
if (pp === null || typeof pp !== "object" || Array.isArray(pp))
|
||||
return false;
|
||||
for (const nested of Object.values(pp as Record<string, unknown>)) {
|
||||
if (!isValidSchema(nested)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("prefixItems" in schema) {
|
||||
if (!Array.isArray(schema.prefixItems) || schema.prefixItems.length === 0)
|
||||
return false;
|
||||
for (const entry of schema.prefixItems) {
|
||||
if (!isValidSchema(entry)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// P2 leaf constraints — type checks only
|
||||
if ("multipleOf" in schema && typeof schema.multipleOf !== "number")
|
||||
return false;
|
||||
if ("minProperties" in schema && typeof schema.minProperties !== "number")
|
||||
return false;
|
||||
if ("maxProperties" in schema && typeof schema.maxProperties !== "number")
|
||||
return false;
|
||||
// "default" accepts any value — no type check needed
|
||||
|
||||
// P3 combinators — recursive sub-schema checks
|
||||
if ("not" in schema && !isValidSchema(schema.not)) return false;
|
||||
if ("contains" in schema && !isValidSchema(schema.contains)) return false;
|
||||
if ("propertyNames" in schema && !isValidSchema(schema.propertyNames))
|
||||
return false;
|
||||
|
||||
// P3 metadata — type checks only
|
||||
if ("examples" in schema && !Array.isArray(schema.examples)) return false;
|
||||
if ("readOnly" in schema && typeof schema.readOnly !== "boolean")
|
||||
return false;
|
||||
if ("writeOnly" in schema && typeof schema.writeOnly !== "boolean")
|
||||
return false;
|
||||
if ("deprecated" in schema && typeof schema.deprecated !== "boolean")
|
||||
return false;
|
||||
if ("$comment" in schema && typeof schema.$comment !== "string") return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -217,8 +293,9 @@ export function validate(store: Store, node: CasNode): boolean {
|
||||
|
||||
/**
|
||||
* Recursively collect values of all properties whose schema has format: 'cas_ref'.
|
||||
* Handles: direct format, anyOf (nullable refs), items (array refs),
|
||||
* properties (nested objects), and additionalProperties (record refs).
|
||||
* Handles: direct format, anyOf/allOf (combinators), oneOf, if/then/else (conditionals),
|
||||
* not, contains, items + prefixItems (arrays), properties (nested objects),
|
||||
* additionalProperties (record refs), and patternProperties (regex-keyed refs).
|
||||
*/
|
||||
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
const result: Hash[] = [];
|
||||
@@ -237,11 +314,55 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (schema.type === "array" && schema.items && Array.isArray(value)) {
|
||||
const itemSchema = schema.items as JSONSchema;
|
||||
for (const item of value as unknown[]) {
|
||||
result.push(...collectRefs(itemSchema, item));
|
||||
// P2: allOf — each sub-schema applies to the same value
|
||||
if (Array.isArray(schema.allOf)) {
|
||||
for (const sub of schema.allOf as JSONSchema[]) {
|
||||
result.push(...collectRefs(sub, value));
|
||||
}
|
||||
}
|
||||
|
||||
// P2: if/then/else — conditional sub-schemas apply to the same value
|
||||
if (schema.if && typeof schema.if === "object") {
|
||||
result.push(...collectRefs(schema.if as JSONSchema, value));
|
||||
}
|
||||
if (schema.then && typeof schema.then === "object") {
|
||||
result.push(...collectRefs(schema.then as JSONSchema, value));
|
||||
}
|
||||
if (schema.else && typeof schema.else === "object") {
|
||||
result.push(...collectRefs(schema.else as JSONSchema, value));
|
||||
}
|
||||
|
||||
if (schema.type === "array" && Array.isArray(value)) {
|
||||
// P2: prefixItems — tuple validation, each item has its own schema
|
||||
if (Array.isArray(schema.prefixItems)) {
|
||||
const tupleSchemas = schema.prefixItems as JSONSchema[];
|
||||
const arr = value as unknown[];
|
||||
for (let i = 0; i < tupleSchemas.length && i < arr.length; i++) {
|
||||
const ts = tupleSchemas[i];
|
||||
if (ts) result.push(...collectRefs(ts, arr[i]));
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.items) {
|
||||
const itemSchema = schema.items as JSONSchema;
|
||||
// When prefixItems exists, items applies only to remaining elements
|
||||
const startIdx = Array.isArray(schema.prefixItems)
|
||||
? (schema.prefixItems as unknown[]).length
|
||||
: 0;
|
||||
const arr = value as unknown[];
|
||||
for (let i = startIdx; i < arr.length; i++) {
|
||||
result.push(...collectRefs(itemSchema, arr[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// P3: contains — sub-schema for array items
|
||||
if (schema.contains && typeof schema.contains === "object") {
|
||||
const containsSchema = schema.contains as JSONSchema;
|
||||
for (const item of value as unknown[]) {
|
||||
result.push(...collectRefs(containsSchema, item));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -264,6 +385,28 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
result.push(...collectRefs(addlSchema, val));
|
||||
}
|
||||
}
|
||||
|
||||
// P2: patternProperties — regex-keyed property schemas
|
||||
if (
|
||||
schema.patternProperties &&
|
||||
typeof schema.patternProperties === "object"
|
||||
) {
|
||||
const pp = schema.patternProperties as Record<string, JSONSchema>;
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const [pat, subSchema] of Object.entries(pp)) {
|
||||
const re = new RegExp(pat);
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
if (re.test(key)) {
|
||||
result.push(...collectRefs(subSchema, val));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// P3: not — sub-schema applies to the same value
|
||||
if (schema.not && typeof schema.not === "object") {
|
||||
result.push(...collectRefs(schema.not as JSONSchema, value));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
describe("createMemoryStore – meta and schema indexes", () => {
|
||||
test("B1. listMeta and listSchemas are empty on a fresh store", () => {
|
||||
const store = createMemoryStore();
|
||||
expect(store.listMeta()).toEqual([]);
|
||||
expect(store.listSchemas()).toEqual([]);
|
||||
});
|
||||
|
||||
test("B2. self-referencing put adds hash to metaSet and listSchemas", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
|
||||
expect(store.listMeta()).toContain(hash);
|
||||
expect(store.listSchemas()).toContain(hash);
|
||||
});
|
||||
|
||||
test("B3. regular put does not add hash to metaSet", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const schemaHash = await store.put(metaHash, { type: "string" });
|
||||
|
||||
expect(store.listMeta()).not.toContain(schemaHash);
|
||||
expect(store.listMeta()).toContain(metaHash);
|
||||
});
|
||||
|
||||
test("B4. schema typed by meta-schema appears in listSchemas", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const s = await store.put(m, { type: "string" });
|
||||
|
||||
const schemas = store.listSchemas();
|
||||
expect(schemas).toContain(m);
|
||||
expect(schemas).toContain(s);
|
||||
|
||||
const meta = store.listMeta();
|
||||
expect(meta).toContain(m);
|
||||
expect(meta).not.toContain(s);
|
||||
});
|
||||
|
||||
test("B5. multiple meta-schemas (versioning)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m1 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v1" });
|
||||
const m2 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v2" });
|
||||
const s1 = await store.put(m1, { type: "string" });
|
||||
const s2 = await store.put(m2, { type: "number" });
|
||||
|
||||
const meta = store.listMeta();
|
||||
expect(meta).toContain(m1);
|
||||
expect(meta).toContain(m2);
|
||||
expect(meta).toHaveLength(2);
|
||||
|
||||
const schemas = store.listSchemas();
|
||||
expect(schemas).toContain(m1);
|
||||
expect(schemas).toContain(m2);
|
||||
expect(schemas).toContain(s1);
|
||||
expect(schemas).toContain(s2);
|
||||
});
|
||||
|
||||
test("B6. idempotent self-referencing put does not duplicate", async () => {
|
||||
const store = createMemoryStore();
|
||||
const payload = { type: "object", title: "dup" };
|
||||
const h1 = await store[BOOTSTRAP_STORE](payload);
|
||||
const h2 = await store[BOOTSTRAP_STORE](payload);
|
||||
expect(h1).toBe(h2);
|
||||
|
||||
const meta = store.listMeta();
|
||||
const occurrences = meta.filter((h) => h === h1).length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
test("B7. delete removes hash from metaSet and listSchemas", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const s = await store.put(m, { type: "string" });
|
||||
|
||||
expect(store.listMeta()).toContain(m);
|
||||
expect(store.listSchemas()).toContain(s);
|
||||
|
||||
store.delete(m);
|
||||
|
||||
expect(store.listMeta()).not.toContain(m);
|
||||
// schemas typed by deleted meta no longer surface
|
||||
expect(store.listSchemas()).not.toContain(s);
|
||||
expect(store.listSchemas()).not.toContain(m);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type { CasNode, Hash } from "./types.js";
|
||||
export function createMemoryStore(): BootstrapCapableStore {
|
||||
const data = new Map<Hash, CasNode>();
|
||||
const byType = new Map<Hash, Set<Hash>>();
|
||||
const metaSet = new Set<Hash>();
|
||||
|
||||
function indexHash(type: Hash, hash: Hash): void {
|
||||
let set = byType.get(type);
|
||||
@@ -24,6 +25,7 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
data.set(hash, { type: hash, payload, timestamp: Date.now() });
|
||||
indexHash(hash, hash);
|
||||
}
|
||||
metaSet.add(hash);
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -56,6 +58,22 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
return Array.from(data.keys());
|
||||
},
|
||||
|
||||
listMeta(): Hash[] {
|
||||
return Array.from(metaSet);
|
||||
},
|
||||
|
||||
listSchemas(): Hash[] {
|
||||
const result = new Set<Hash>();
|
||||
for (const meta of metaSet) {
|
||||
result.add(meta);
|
||||
const set = byType.get(meta);
|
||||
if (set) {
|
||||
for (const h of set) result.add(h);
|
||||
}
|
||||
}
|
||||
return Array.from(result);
|
||||
},
|
||||
|
||||
delete(hash: Hash): void {
|
||||
const node = data.get(hash);
|
||||
if (node) {
|
||||
@@ -68,6 +86,7 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
byType.delete(node.type);
|
||||
}
|
||||
}
|
||||
metaSet.delete(hash);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -25,5 +25,7 @@ export type Store = {
|
||||
has(hash: Hash): boolean;
|
||||
listByType(typeHash: Hash): Hash[];
|
||||
listAll(): Hash[];
|
||||
listMeta(): Hash[];
|
||||
listSchemas(): Hash[];
|
||||
delete(hash: Hash): void;
|
||||
};
|
||||
|
||||
@@ -72,8 +72,28 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
expect(properties).not.toHaveProperty("$ref");
|
||||
expect(properties).not.toHaveProperty("$id");
|
||||
expect(properties).not.toHaveProperty("$defs");
|
||||
expect(properties).not.toHaveProperty("allOf");
|
||||
expect(properties).not.toHaveProperty("not");
|
||||
|
||||
// P2 keywords should be present
|
||||
expect(properties).toHaveProperty("allOf");
|
||||
expect(properties).toHaveProperty("if");
|
||||
expect(properties).toHaveProperty("then");
|
||||
expect(properties).toHaveProperty("else");
|
||||
expect(properties).toHaveProperty("patternProperties");
|
||||
expect(properties).toHaveProperty("prefixItems");
|
||||
expect(properties).toHaveProperty("multipleOf");
|
||||
expect(properties).toHaveProperty("minProperties");
|
||||
expect(properties).toHaveProperty("maxProperties");
|
||||
expect(properties).toHaveProperty("default");
|
||||
|
||||
// P3 keywords should be present
|
||||
expect(properties).toHaveProperty("not");
|
||||
expect(properties).toHaveProperty("contains");
|
||||
expect(properties).toHaveProperty("propertyNames");
|
||||
expect(properties).toHaveProperty("examples");
|
||||
expect(properties).toHaveProperty("readOnly");
|
||||
expect(properties).toHaveProperty("writeOnly");
|
||||
expect(properties).toHaveProperty("deprecated");
|
||||
expect(properties).toHaveProperty("$comment");
|
||||
});
|
||||
|
||||
test("1.5: Meta-schema node type equals its own hash", async () => {
|
||||
|
||||
Reference in New Issue
Block a user