Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0dca8a5521 | |||
| 5aad956c83 | |||
| 038c901f4b | |||
| f4cf92e128 |
@@ -1,32 +1,5 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`Phase 1: CAS Core 1.3 schema get returns schema JSON (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"age": {
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.4 schema list shows registered schemas 1`] = `
|
||||
"8J5E9WCD54ZJZ (unnamed)
|
||||
89ZQWMV9PTC7B (unnamed)
|
||||
86QB0Q5VA7797 (unnamed)
|
||||
AKEX4KYV98MGT (unnamed)
|
||||
AGSJVKM01WNKZ (unnamed)
|
||||
7XX5H51CVD9H0 (unnamed)"
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
|
||||
{
|
||||
"payload": {
|
||||
@@ -43,25 +16,6 @@ exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `"
|
||||
|
||||
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `"ERARPP19YJT05"`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.13 cat returns full node (snapshot) 1`] = `
|
||||
{
|
||||
"payload": {
|
||||
"age": 30,
|
||||
"name": "Alice",
|
||||
},
|
||||
"type": "7XX5H51CVD9H0",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.14 cat --payload returns only payload (snapshot) 1`] = `
|
||||
"{
|
||||
"age": 30,
|
||||
"name": "Alice"
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 2: Schema Validation 2.2 schema validate on valid node returns valid 1`] = `"valid"`;
|
||||
|
||||
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||
@@ -258,26 +212,20 @@ exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: json-c
|
||||
|
||||
exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`;
|
||||
|
||||
exports[`Phase 7: Edge Cases 7.5 schema put invalid schema errors 1`] = `"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema"`;
|
||||
|
||||
exports[`Phase 7: Edge Cases 7.6 no subcommand shows help text 1`] = `
|
||||
exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = `
|
||||
"Usage: json-cas [--store <path>] [--json] <command> [args]
|
||||
|
||||
Commands:
|
||||
schema put <file.json> Register schema, print type hash
|
||||
schema get <type-hash> Print schema JSON
|
||||
schema list List all schemas (name + hash)
|
||||
schema validate <hash> Validate node against its schema
|
||||
put <type-hash> <file.json> Store node, print hash
|
||||
get <hash> Print node as JSON
|
||||
has <hash> Print true/false
|
||||
verify <hash> Verify integrity, print ok/corrupted
|
||||
verify <hash> Verify integrity + schema, print ok/corrupted/invalid
|
||||
refs <hash> List direct cas_ref edges
|
||||
walk <hash> [--format tree] Recursive traversal
|
||||
hash <type-hash> <file.json> Compute hash without storing (dry run)
|
||||
render <hash> [options] Render node as YAML with resolution decay
|
||||
render --pipe/-p [options] Render { type, value } from stdin
|
||||
cat <hash> [--payload] Output node (--payload for payload only)
|
||||
list --type <hash-or-alias> List all hashes for a given type
|
||||
var set <name> <hash> [--tag <tag>...] Create/update a variable
|
||||
var get <name> --schema <hash> Get a variable by name + schema
|
||||
var delete <name> [--schema <hash>] Delete variable(s)
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
mkdirSync,
|
||||
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 type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||
import { createFsStore, openStore as openFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||
const entrypoint = resolve(import.meta.dir, "index.ts");
|
||||
|
||||
/**
|
||||
* Register a schema directly via the library (CLI schema put was removed).
|
||||
* Returns the type hash.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
storePath?: string,
|
||||
@@ -130,86 +153,6 @@ async function runCliAlias(...args: string[]): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
describe("@ Alias Resolution - schema get", () => {
|
||||
test("ucas schema get @string should work", async () => {
|
||||
await runCliAlias("init"); // Initialize store
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||
"schema",
|
||||
"get",
|
||||
"@string",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "string" });
|
||||
});
|
||||
|
||||
test("ucas schema get @number should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@number");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "number" });
|
||||
});
|
||||
|
||||
test("ucas schema get @object should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@object");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "object" });
|
||||
});
|
||||
|
||||
test("ucas schema get @array should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@array");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "array" });
|
||||
});
|
||||
|
||||
test("ucas schema get @bool should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@bool");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "boolean" });
|
||||
});
|
||||
|
||||
test("ucas schema get @schema should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@schema");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toHaveProperty("type", "object");
|
||||
expect(schema).toHaveProperty(
|
||||
"description",
|
||||
"json-cas JSON Schema meta-schema",
|
||||
);
|
||||
});
|
||||
|
||||
test("ucas schema get @invalid should fail gracefully", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stderr, exitCode } = await runCliAlias("schema", "get", "@invalid");
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Schema not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@ Alias Resolution - put", () => {
|
||||
test("ucas put @string <file> should resolve alias", async () => {
|
||||
await runCliAlias("init");
|
||||
@@ -357,10 +300,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Create valid payload
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
@@ -403,10 +343,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload with only required properties
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
@@ -448,10 +385,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload with nested structure
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
@@ -513,10 +447,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload with name as number
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
@@ -560,10 +491,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Empty payload
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
@@ -596,10 +524,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload with extra property
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
@@ -634,10 +559,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
items: { type: "string" },
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload is an object
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
@@ -676,10 +598,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Payload with wrong nested type
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
@@ -760,10 +679,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Invalid payload
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
@@ -798,10 +714,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Valid cas_ref
|
||||
const validFile = join(tmpStore, "valid.json");
|
||||
@@ -843,11 +756,8 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { exitCode } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const hash = await putSchemaFile(tmpStore, schemaFile);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
@@ -869,10 +779,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
required: ["name"],
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||
@@ -903,10 +810,7 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
properties: { name: { type: "string" } },
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||
@@ -941,10 +845,7 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
properties: { name: { type: "string" } },
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Create node
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
@@ -1005,10 +906,7 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
// Create child node
|
||||
const childFile = join(tmpStore, "child.json");
|
||||
@@ -1077,10 +975,7 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
properties: { name: { type: "string" } },
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Bob" }));
|
||||
@@ -1144,10 +1039,7 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
properties: { name: { type: "string" } },
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" }));
|
||||
@@ -1183,10 +1075,7 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
properties: { name: { type: "string" } },
|
||||
}),
|
||||
);
|
||||
const { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||
|
||||
const nodeFile = join(tmpStore, "node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Test" }));
|
||||
@@ -1309,140 +1198,6 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- schema put - invalid schema error handling ----
|
||||
|
||||
describe("schema put - invalid schema error handling", () => {
|
||||
test("invalid schema - unknown type value shows clean error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "invalid-schema.json");
|
||||
writeFileSync(schemaFile, JSON.stringify({ type: "invalid" }));
|
||||
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Invalid schema");
|
||||
expect(stderr).not.toContain("at ");
|
||||
expect(stdout).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid schema - unknown key shows clean error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "invalid-schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({ type: "string", unknownKey: true }),
|
||||
);
|
||||
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Invalid schema");
|
||||
expect(stderr).not.toContain("at ");
|
||||
expect(stdout).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid schema - invalid nested schema shows clean error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "invalid-schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "invalid" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Invalid schema");
|
||||
expect(stderr).not.toContain("at ");
|
||||
expect(stdout).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid schema - non-object root shows clean error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "invalid-schema.json");
|
||||
writeFileSync(schemaFile, JSON.stringify(["type", "string"]));
|
||||
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Invalid schema");
|
||||
expect(stderr).not.toContain("at ");
|
||||
expect(stdout).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("valid schema still works (regression)", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "valid-schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
required: ["name"],
|
||||
}),
|
||||
);
|
||||
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
} 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
@@ -57,7 +57,7 @@ function stripVolatile(json: string): unknown {
|
||||
// ---- Phase 1: CAS Core ----
|
||||
|
||||
describe("Phase 1: CAS Core", () => {
|
||||
test("1.1 schema put returns type hash (auto-bootstraps store)", async () => {
|
||||
test("1.1 init + put with @object bootstraps store", async () => {
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
@@ -71,23 +71,16 @@ describe("Phase 1: CAS Core", () => {
|
||||
additionalProperties: false,
|
||||
}),
|
||||
);
|
||||
const { stdout, exitCode } = await runCli(["schema", "put", schemaFile]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
typeHash = stdout;
|
||||
});
|
||||
|
||||
test("1.3 schema get returns schema JSON (snapshot)", async () => {
|
||||
const { stdout, exitCode } = await runCli(["schema", "get", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("1.4 schema list shows registered schemas", async () => {
|
||||
const { stdout, exitCode } = await runCli(["schema", "list"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toMatchSnapshot();
|
||||
expect(stdout).toContain(typeHash);
|
||||
// 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 () => {
|
||||
@@ -142,19 +135,10 @@ describe("Phase 1: CAS Core", () => {
|
||||
expect(stdout).toBe(nodeHash);
|
||||
});
|
||||
|
||||
test("1.13 cat returns full node (snapshot)", async () => {
|
||||
const { stdout, exitCode } = await runCli(["cat", 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(stripVolatile(stdout)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("1.14 cat --payload returns only payload (snapshot)", async () => {
|
||||
const { stdout, exitCode } = await runCli(["cat", nodeHash, "--payload"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toMatchSnapshot();
|
||||
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
||||
expect(parsed).not.toHaveProperty("type");
|
||||
expect(parsed).toHaveProperty("name", "Alice");
|
||||
expect(stdout).toContain(nodeHash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,10 +160,10 @@ describe("Phase 2: Schema Validation", () => {
|
||||
// Do NOT snapshot stderr — it embeds a machine-specific tmp path
|
||||
});
|
||||
|
||||
test("2.2 schema validate on valid node returns valid", async () => {
|
||||
const { stdout, exitCode } = await runCli(["schema", "validate", nodeHash]);
|
||||
test("2.2 verify on valid node returns ok (hash + schema)", async () => {
|
||||
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toMatchSnapshot();
|
||||
expect(stdout).toBe("ok");
|
||||
});
|
||||
|
||||
test("2.3 put against non-existent schema hash fails", async () => {
|
||||
@@ -504,20 +488,7 @@ describe("Phase 7: Edge Cases", () => {
|
||||
expect(stderr).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("7.5 schema put invalid schema errors", async () => {
|
||||
const badSchemaFile = join(tmpStore, "bad-schema.json");
|
||||
writeFileSync(badSchemaFile, JSON.stringify({ type: "invalid" }));
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"schema",
|
||||
"put",
|
||||
badSchemaFile,
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("7.6 no subcommand shows help text", async () => {
|
||||
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);
|
||||
@@ -525,7 +496,7 @@ describe("Phase 7: Edge Cases", () => {
|
||||
expect(combined.toLowerCase()).toContain("usage");
|
||||
});
|
||||
|
||||
test("7.7 --store path is a file errors", async () => {
|
||||
test("7.6 --store path is a file errors", async () => {
|
||||
const fileAsStore = join(tmpStore, "not-a-directory");
|
||||
writeFileSync(fileAsStore, "test");
|
||||
const proc = Bun.spawn(
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
refs,
|
||||
renderAsync,
|
||||
renderDirect,
|
||||
SchemaValidationError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
validate,
|
||||
@@ -41,6 +40,7 @@ const VALUE_FLAGS = new Set([
|
||||
"decay",
|
||||
"epsilon",
|
||||
"inline",
|
||||
"type",
|
||||
]);
|
||||
|
||||
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
@@ -227,61 +227,6 @@ function parseTagsLabels(args: string[]): {
|
||||
|
||||
// ---- Commands ----
|
||||
|
||||
async function cmdSchemaPut(args: string[]): Promise<void> {
|
||||
const file = args[0];
|
||||
if (!file) die("Usage: json-cas schema put <file.json>");
|
||||
const schema = readJsonFile(file) as JSONSchema;
|
||||
const store = await openStore();
|
||||
try {
|
||||
const hash = await putSchema(store, schema);
|
||||
console.log(hash);
|
||||
} catch (e) {
|
||||
if (e instanceof SchemaValidationError) {
|
||||
die(e.message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdSchemaGet(args: string[]): Promise<void> {
|
||||
const hashOrAlias = args[0];
|
||||
if (!hashOrAlias) die("Usage: json-cas schema get <type-hash>");
|
||||
const hash = await resolveTypeHash(hashOrAlias);
|
||||
const store = await openStore();
|
||||
const schema = getSchema(store, hash);
|
||||
if (schema === null) die(`Schema not found: ${hashOrAlias}`);
|
||||
out(schema);
|
||||
}
|
||||
|
||||
async function cmdSchemaList(): Promise<void> {
|
||||
const store = await openStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) throw new Error("Meta-schema not found");
|
||||
for (const hash of store.listByType(metaHash)) {
|
||||
if (hash === metaHash) continue;
|
||||
const node = store.get(hash);
|
||||
if (node !== null) {
|
||||
const schema = node.payload as JSONSchema;
|
||||
const name =
|
||||
(schema.title as string | undefined) ??
|
||||
(schema.description as string | undefined) ??
|
||||
"(unnamed)";
|
||||
console.log(`${hash} ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdSchemaValidate(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas schema validate <hash>");
|
||||
const store = await openStore();
|
||||
const node = store.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
const valid = validate(store, node);
|
||||
console.log(valid ? "valid" : "invalid");
|
||||
}
|
||||
|
||||
async function cmdPut(args: string[]): Promise<void> {
|
||||
const typeHashOrAlias = args[0];
|
||||
const file = args[1];
|
||||
@@ -334,7 +279,12 @@ async function cmdVerify(args: string[]): Promise<void> {
|
||||
const node = store.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
const ok = await verify(hash, node);
|
||||
console.log(ok ? "ok" : "corrupted");
|
||||
if (!ok) {
|
||||
console.log("corrupted");
|
||||
} else {
|
||||
const valid = validate(store, node);
|
||||
console.log(valid ? "ok" : "invalid");
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdRefs(args: string[]): Promise<void> {
|
||||
@@ -509,19 +459,6 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdCat(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas cat <hash>");
|
||||
const store = await openStore();
|
||||
const node = store.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
if (flags.payload === true) {
|
||||
out(node.payload);
|
||||
} else {
|
||||
out(node);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarSet(args: string[]): Promise<void> {
|
||||
const name = args[0];
|
||||
const value = args[1];
|
||||
@@ -883,25 +820,32 @@ async function cmdGc(_args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdList(_args: string[]): Promise<void> {
|
||||
const typeFlag = flags.type;
|
||||
if (typeof typeFlag !== "string")
|
||||
die("Usage: json-cas list --type <hash-or-alias>");
|
||||
const typeHash = await resolveTypeHash(typeFlag);
|
||||
const store = await openStore();
|
||||
for (const hash of store.listByType(typeHash)) {
|
||||
console.log(hash);
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`\
|
||||
Usage: json-cas [--store <path>] [--json] <command> [args]
|
||||
|
||||
Commands:
|
||||
schema put <file.json> Register schema, print type hash
|
||||
schema get <type-hash> Print schema JSON
|
||||
schema list List all schemas (name + hash)
|
||||
schema validate <hash> Validate node against its schema
|
||||
put <type-hash> <file.json> Store node, print hash
|
||||
get <hash> Print node as JSON
|
||||
has <hash> Print true/false
|
||||
verify <hash> Verify integrity, print ok/corrupted
|
||||
verify <hash> Verify integrity + schema, print ok/corrupted/invalid
|
||||
refs <hash> List direct cas_ref edges
|
||||
walk <hash> [--format tree] Recursive traversal
|
||||
hash <type-hash> <file.json> Compute hash without storing (dry run)
|
||||
render <hash> [options] Render node as YAML with resolution decay
|
||||
render --pipe/-p [options] Render { type, value } from stdin
|
||||
cat <hash> [--payload] Output node (--payload for payload only)
|
||||
list --type <hash-or-alias> List all hashes for a given type
|
||||
var set <name> <hash> [--tag <tag>...] Create/update a variable
|
||||
var get <name> --schema <hash> Get a variable by name + schema
|
||||
var delete <name> [--schema <hash>] Delete variable(s)
|
||||
@@ -936,27 +880,6 @@ if (!cmd) {
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case "schema": {
|
||||
const [sub, ...subRest] = rest;
|
||||
switch (sub) {
|
||||
case "put":
|
||||
await cmdSchemaPut(subRest);
|
||||
break;
|
||||
case "get":
|
||||
await cmdSchemaGet(subRest);
|
||||
break;
|
||||
case "list":
|
||||
await cmdSchemaList();
|
||||
break;
|
||||
case "validate":
|
||||
await cmdSchemaValidate(subRest);
|
||||
break;
|
||||
default:
|
||||
die(`Unknown schema subcommand: ${sub ?? "(none)"}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "put":
|
||||
await cmdPut(rest);
|
||||
break;
|
||||
@@ -989,8 +912,8 @@ switch (cmd) {
|
||||
await cmdRender(rest);
|
||||
break;
|
||||
|
||||
case "cat":
|
||||
await cmdCat(rest);
|
||||
case "list":
|
||||
await cmdList(rest);
|
||||
break;
|
||||
|
||||
case "var": {
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import type { JSONSchema } from "./schema.js";
|
||||
import { getSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
const OUTPUT_ALIASES = [
|
||||
"@output/put",
|
||||
"@output/get",
|
||||
"@output/has",
|
||||
"@output/hash",
|
||||
"@output/verify",
|
||||
"@output/refs",
|
||||
"@output/walk",
|
||||
"@output/list",
|
||||
"@output/var-set",
|
||||
"@output/var-get",
|
||||
"@output/var-delete",
|
||||
"@output/var-tag",
|
||||
"@output/var-list",
|
||||
"@output/template-set",
|
||||
"@output/template-get",
|
||||
"@output/template-list",
|
||||
"@output/template-delete",
|
||||
"@output/gc",
|
||||
] as const;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Built-in Schema Registration Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bootstrap - Built-in Schemas", () => {
|
||||
test("should return map of built-in schema aliases to hashes", async () => {
|
||||
test("should return map of 24 built-in schema aliases to hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
// Should return object with 6 aliases
|
||||
// Should return object with 6 primitive + 18 output aliases = 24
|
||||
expect(builtinSchemas).toHaveProperty("@schema");
|
||||
expect(builtinSchemas).toHaveProperty("@string");
|
||||
expect(builtinSchemas).toHaveProperty("@number");
|
||||
@@ -20,6 +42,12 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
expect(builtinSchemas).toHaveProperty("@array");
|
||||
expect(builtinSchemas).toHaveProperty("@bool");
|
||||
|
||||
for (const alias of OUTPUT_ALIASES) {
|
||||
expect(builtinSchemas).toHaveProperty(alias);
|
||||
}
|
||||
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(24);
|
||||
|
||||
// All values should be valid hashes
|
||||
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
||||
expect(typeof hash).toBe("string");
|
||||
@@ -127,3 +155,164 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// @output/* Schema Registration Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bootstrap - @output/* Schemas", () => {
|
||||
test("each @output/* schema has a title", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
for (const alias of OUTPUT_ALIASES) {
|
||||
const hash = aliases[alias];
|
||||
if (!hash) throw new Error(`${alias} not found`);
|
||||
|
||||
const schema = getSchema(store, hash) as JSONSchema;
|
||||
expect(schema).not.toBeNull();
|
||||
expect(typeof schema.title).toBe("string");
|
||||
expect((schema.title as string).startsWith("ucas ")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("@output/put schema describes a cas_ref string", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@output/put"];
|
||||
if (!hash) throw new Error("@output/put not found");
|
||||
|
||||
const schema = getSchema(store, hash);
|
||||
expect(schema).toEqual({
|
||||
type: "string",
|
||||
format: "cas_ref",
|
||||
title: "ucas put result",
|
||||
});
|
||||
});
|
||||
|
||||
test("@output/get schema describes object with type, payload, timestamp", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@output/get"];
|
||||
if (!hash) throw new Error("@output/get not found");
|
||||
|
||||
const schema = getSchema(store, hash) as JSONSchema;
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.title).toBe("ucas get result");
|
||||
|
||||
const props = schema.properties as Record<string, JSONSchema>;
|
||||
expect(props.type).toEqual({ type: "string", format: "cas_ref" });
|
||||
expect(props.payload).toEqual({});
|
||||
expect(props.timestamp).toEqual({ type: "number" });
|
||||
});
|
||||
|
||||
test("@output/has schema describes a boolean", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@output/has"];
|
||||
if (!hash) throw new Error("@output/has not found");
|
||||
|
||||
expect(getSchema(store, hash)).toEqual({
|
||||
type: "boolean",
|
||||
title: "ucas has result",
|
||||
});
|
||||
});
|
||||
|
||||
test("@output/verify schema describes enum of ok|corrupted|invalid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@output/verify"];
|
||||
if (!hash) throw new Error("@output/verify not found");
|
||||
|
||||
const schema = getSchema(store, hash);
|
||||
expect(schema).toEqual({
|
||||
type: "string",
|
||||
enum: ["ok", "corrupted", "invalid"],
|
||||
title: "ucas verify result",
|
||||
});
|
||||
});
|
||||
|
||||
test("@output/refs schema describes array of cas_ref strings", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@output/refs"];
|
||||
if (!hash) throw new Error("@output/refs not found");
|
||||
|
||||
expect(getSchema(store, hash)).toEqual({
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
title: "ucas refs result",
|
||||
});
|
||||
});
|
||||
|
||||
test("@output/gc schema describes object with gc stats fields", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@output/gc"];
|
||||
if (!hash) throw new Error("@output/gc not found");
|
||||
|
||||
const schema = getSchema(store, hash) as JSONSchema;
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.title).toBe("ucas gc result");
|
||||
|
||||
const props = schema.properties as Record<string, JSONSchema>;
|
||||
expect(props.total).toEqual({ type: "number" });
|
||||
expect(props.reachable).toEqual({ type: "number" });
|
||||
expect(props.collected).toEqual({ type: "number" });
|
||||
expect(props.scanned).toEqual({ type: "number" });
|
||||
});
|
||||
|
||||
test("@output/var-set schema describes a Variable object", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@output/var-set"];
|
||||
if (!hash) throw new Error("@output/var-set not found");
|
||||
|
||||
const schema = getSchema(store, hash) as JSONSchema;
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.title).toBe("ucas var set result");
|
||||
|
||||
const props = schema.properties as Record<string, JSONSchema>;
|
||||
expect(props.name).toEqual({ type: "string" });
|
||||
expect(props.schema).toEqual({ type: "string", format: "cas_ref" });
|
||||
expect(props.value).toEqual({ type: "string", format: "cas_ref" });
|
||||
});
|
||||
|
||||
test("@output/var-list schema describes array of Variable objects", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@output/var-list"];
|
||||
if (!hash) throw new Error("@output/var-list not found");
|
||||
|
||||
const schema = getSchema(store, hash) as JSONSchema;
|
||||
expect(schema.type).toBe("array");
|
||||
expect(schema.title).toBe("ucas var list result");
|
||||
|
||||
const items = schema.items as JSONSchema;
|
||||
expect(items.type).toBe("object");
|
||||
const props = items.properties as Record<string, JSONSchema>;
|
||||
expect(props.name).toEqual({ type: "string" });
|
||||
});
|
||||
|
||||
test("@output/template-delete schema describes object with deleted boolean", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@output/template-delete"];
|
||||
if (!hash) throw new Error("@output/template-delete not found");
|
||||
|
||||
expect(getSchema(store, hash)).toEqual({
|
||||
type: "object",
|
||||
properties: { deleted: { type: "boolean" } },
|
||||
title: "ucas template delete result",
|
||||
});
|
||||
});
|
||||
|
||||
test("all @output/* schemas are distinct hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
const outputHashes = OUTPUT_ALIASES.map((alias) => aliases[alias]);
|
||||
const uniqueHashes = new Set(outputHashes);
|
||||
expect(uniqueHashes.size).toBe(OUTPUT_ALIASES.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,9 +63,168 @@ const BOOTSTRAP_PAYLOAD = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const VARIABLE_PROPERTIES = {
|
||||
name: { type: "string" },
|
||||
schema: { type: "string", format: "cas_ref" },
|
||||
value: { type: "string", format: "cas_ref" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
tags: { type: "object" },
|
||||
labels: { type: "array", items: { type: "string" } },
|
||||
} as const;
|
||||
|
||||
const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
readonly [alias: string, schema: Record<string, unknown>]
|
||||
> = [
|
||||
[
|
||||
"@output/put",
|
||||
{ type: "string", format: "cas_ref", title: "ucas put result" },
|
||||
],
|
||||
[
|
||||
"@output/get",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", format: "cas_ref" },
|
||||
payload: {},
|
||||
timestamp: { type: "number" },
|
||||
},
|
||||
title: "ucas get result",
|
||||
},
|
||||
],
|
||||
["@output/has", { type: "boolean", title: "ucas has result" }],
|
||||
[
|
||||
"@output/hash",
|
||||
{ type: "string", format: "cas_ref", title: "ucas hash result" },
|
||||
],
|
||||
[
|
||||
"@output/verify",
|
||||
{
|
||||
type: "string",
|
||||
enum: ["ok", "corrupted", "invalid"],
|
||||
title: "ucas verify result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/refs",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
title: "ucas refs result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/walk",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
title: "ucas walk result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/list",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
title: "ucas list result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-set",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ucas var set result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-get",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ucas var get result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-delete",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ucas var delete result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-tag",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ucas var tag result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-list",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "object", properties: { ...VARIABLE_PROPERTIES } },
|
||||
title: "ucas var list result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/template-set",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
schemaHash: { type: "string", format: "cas_ref" },
|
||||
contentHash: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
title: "ucas template set result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/template-get",
|
||||
{ type: "string", title: "ucas template get result" },
|
||||
],
|
||||
[
|
||||
"@output/template-list",
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
schemaHash: { type: "string", format: "cas_ref" },
|
||||
contentHash: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
title: "ucas template list result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/template-delete",
|
||||
{
|
||||
type: "object",
|
||||
properties: { deleted: { type: "boolean" } },
|
||||
title: "ucas template delete result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/gc",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
total: { type: "number" },
|
||||
reachable: { type: "number" },
|
||||
collected: { type: "number" },
|
||||
scanned: { type: "number" },
|
||||
},
|
||||
title: "ucas gc result",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Write the meta-schema seed node into the store and register built-in schemas.
|
||||
* The returned object contains aliases for the meta-schema and 5 primitive schemas.
|
||||
* The returned object contains aliases for the meta-schema, 5 primitive schemas,
|
||||
* and 18 @output/* schemas (24 total).
|
||||
* Idempotent: calling bootstrap multiple times returns the same hashes.
|
||||
*/
|
||||
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
||||
@@ -83,8 +242,8 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
||||
const arrayHash = await store.put(metaHash, { type: "array" });
|
||||
const boolHash = await store.put(metaHash, { type: "boolean" });
|
||||
|
||||
// 3. Return map of aliases to hashes
|
||||
return {
|
||||
// 3. Register @output/* schemas
|
||||
const aliases: Record<string, Hash> = {
|
||||
"@schema": metaHash,
|
||||
"@string": stringHash,
|
||||
"@number": numberHash,
|
||||
@@ -92,4 +251,10 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
||||
"@array": arrayHash,
|
||||
"@bool": boolHash,
|
||||
};
|
||||
|
||||
for (const [alias, schema] of OUTPUT_SCHEMAS) {
|
||||
aliases[alias] = await store.put(metaHash, schema);
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ describe("bootstrap", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a map with 6 built-in schema aliases", async () => {
|
||||
test("returns a map with 24 built-in schema aliases", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
@@ -280,6 +280,8 @@ describe("bootstrap", () => {
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(24);
|
||||
});
|
||||
|
||||
test("meta-schema node is stored and retrievable", async () => {
|
||||
@@ -316,7 +318,7 @@ describe("bootstrap", () => {
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
// All 6 built-in schemas should be typed by the meta-schema
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
|
||||
// All 24 built-in schemas should be typed by the meta-schema
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ export { cborEncode } from "./cbor.js";
|
||||
export { type GcStats, gc } from "./gc.js";
|
||||
export { computeHash, computeSelfHash } from "./hash.js";
|
||||
export { renderWithTemplate } from "./liquid-render.js";
|
||||
export { registerOutputTemplates } from "./output-templates.js";
|
||||
export {
|
||||
type RenderOptions,
|
||||
render,
|
||||
@@ -34,3 +35,4 @@ export {
|
||||
VariableStore,
|
||||
} from "./variable-store.js";
|
||||
export { verify } from "./verify.js";
|
||||
export { wrapEnvelope } from "./wrap-envelope.js";
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { registerOutputTemplates } from "./output-templates.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
import { createVariableStore } from "./variable-store.js";
|
||||
|
||||
const OUTPUT_ALIASES = [
|
||||
"@output/put",
|
||||
"@output/get",
|
||||
"@output/has",
|
||||
"@output/hash",
|
||||
"@output/verify",
|
||||
"@output/refs",
|
||||
"@output/walk",
|
||||
"@output/list",
|
||||
"@output/var-set",
|
||||
"@output/var-get",
|
||||
"@output/var-delete",
|
||||
"@output/var-tag",
|
||||
"@output/var-list",
|
||||
"@output/template-set",
|
||||
"@output/template-get",
|
||||
"@output/template-list",
|
||||
"@output/template-delete",
|
||||
"@output/gc",
|
||||
] as const;
|
||||
|
||||
describe("registerOutputTemplates", () => {
|
||||
let store: Store;
|
||||
let varStore: VariableStore;
|
||||
let tempDir: string;
|
||||
|
||||
afterEach(async () => {
|
||||
varStore.close();
|
||||
await rm(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
test("registers a template for every @output/* schema", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
|
||||
const registered = await registerOutputTemplates(store, varStore);
|
||||
|
||||
expect(Object.keys(registered)).toHaveLength(18);
|
||||
|
||||
for (const alias of OUTPUT_ALIASES) {
|
||||
expect(registered).toHaveProperty(alias);
|
||||
}
|
||||
});
|
||||
|
||||
test("each template is retrievable via @ucas/template/text/<hash>", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||
store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
|
||||
await registerOutputTemplates(store, varStore);
|
||||
|
||||
const stringHash = aliases["@string"];
|
||||
if (!stringHash) throw new Error("@string not found");
|
||||
|
||||
for (const alias of OUTPUT_ALIASES) {
|
||||
const schemaHash = aliases[alias];
|
||||
if (!schemaHash) throw new Error(`${alias} not found`);
|
||||
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
const variable = varStore.get(varName, stringHash);
|
||||
if (variable === null) throw new Error(`Variable ${varName} not found`);
|
||||
|
||||
const templateNode = store.get(variable.value);
|
||||
if (templateNode === null)
|
||||
throw new Error(`Template node ${variable.value} not found`);
|
||||
expect(typeof templateNode.payload).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("is idempotent — safe to call multiple times", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
|
||||
const first = await registerOutputTemplates(store, varStore);
|
||||
const second = await registerOutputTemplates(store, varStore);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
test("@output/put template contains payload reference", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||
store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
|
||||
await registerOutputTemplates(store, varStore);
|
||||
|
||||
const putHash = aliases["@output/put"];
|
||||
if (!putHash) throw new Error("@output/put not found");
|
||||
const stringHash = aliases["@string"];
|
||||
if (!stringHash) throw new Error("@string not found");
|
||||
|
||||
const variable = varStore.get(`@ucas/template/text/${putHash}`, stringHash);
|
||||
if (variable === null)
|
||||
throw new Error("@output/put template variable not found");
|
||||
|
||||
const templateNode = store.get(variable.value);
|
||||
if (templateNode === null) throw new Error("Template node not found");
|
||||
expect(templateNode.payload).toBe("{{ payload }}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
|
||||
const DEFAULT_TEMPLATES: ReadonlyArray<
|
||||
readonly [alias: string, template: string]
|
||||
> = [
|
||||
["@output/put", "{{ payload }}"],
|
||||
[
|
||||
"@output/get",
|
||||
"type: {{ payload.type }}\ntimestamp: {{ payload.timestamp }}",
|
||||
],
|
||||
["@output/has", "{{ payload }}"],
|
||||
["@output/hash", "{{ payload }}"],
|
||||
["@output/verify", "{{ payload }}"],
|
||||
["@output/refs", "{% for ref in payload %}{{ ref }}\n{% endfor %}"],
|
||||
["@output/walk", "{% for item in payload %}{{ item }}\n{% endfor %}"],
|
||||
["@output/list", "{% for item in payload %}{{ item }}\n{% endfor %}"],
|
||||
[
|
||||
"@output/var-set",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||
],
|
||||
[
|
||||
"@output/var-get",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||
],
|
||||
[
|
||||
"@output/var-delete",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||
],
|
||||
[
|
||||
"@output/var-tag",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||
],
|
||||
[
|
||||
"@output/var-list",
|
||||
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}",
|
||||
],
|
||||
[
|
||||
"@output/template-set",
|
||||
"schemaHash: {{ payload.schemaHash }}\ncontentHash: {{ payload.contentHash }}",
|
||||
],
|
||||
["@output/template-get", "{{ payload }}"],
|
||||
[
|
||||
"@output/template-list",
|
||||
"{% for t in payload %}schemaHash: {{ t.schemaHash }}\ncontentHash: {{ t.contentHash }}\n{% endfor %}",
|
||||
],
|
||||
["@output/template-delete", "deleted: {{ payload.deleted }}"],
|
||||
[
|
||||
"@output/gc",
|
||||
"total: {{ payload.total }}\nreachable: {{ payload.reachable }}\ncollected: {{ payload.collected }}\nscanned: {{ payload.scanned }}",
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Register default LiquidJS templates for all @output/* schemas.
|
||||
* Each template is stored as a @string CAS node and bound to
|
||||
* the variable `@ucas/template/text/<schema-hash>`.
|
||||
*
|
||||
* Idempotent: safe to call multiple times.
|
||||
*/
|
||||
export async function registerOutputTemplates(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
): Promise<Record<string, Hash>> {
|
||||
const aliases = await bootstrap(store);
|
||||
const stringHash = aliases["@string"];
|
||||
if (stringHash === undefined) {
|
||||
throw new Error("@string schema not found in bootstrap result");
|
||||
}
|
||||
|
||||
const registered: Record<string, Hash> = {};
|
||||
|
||||
for (const [alias, template] of DEFAULT_TEMPLATES) {
|
||||
const schemaHash = aliases[alias];
|
||||
if (schemaHash === undefined) {
|
||||
throw new Error(`Schema alias not found: ${alias}`);
|
||||
}
|
||||
|
||||
const contentHash = await store.put(stringHash, template);
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
varStore.set(varName, contentHash);
|
||||
registered[alias] = contentHash;
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import { wrapEnvelope } from "./wrap-envelope.js";
|
||||
|
||||
describe("wrapEnvelope", () => {
|
||||
test("resolves @output/put alias and returns envelope", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
const envelope = await wrapEnvelope(store, "@output/put", "AAAAAAAAAAAAA");
|
||||
|
||||
expect(envelope.type).toBe(aliases["@output/put"]);
|
||||
expect(envelope.value).toBe("AAAAAAAAAAAAA");
|
||||
});
|
||||
|
||||
test("resolves @output/has alias with boolean value", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
const envelope = await wrapEnvelope(store, "@output/has", true);
|
||||
|
||||
expect(envelope.type).toBe(aliases["@output/has"]);
|
||||
expect(envelope.value).toBe(true);
|
||||
});
|
||||
|
||||
test("resolves @output/gc alias with object value", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
const gcStats = { total: 100, reachable: 80, collected: 20, scanned: 5 };
|
||||
const envelope = await wrapEnvelope(store, "@output/gc", gcStats);
|
||||
|
||||
expect(envelope.type).toBe(aliases["@output/gc"]);
|
||||
expect(envelope.value).toEqual(gcStats);
|
||||
});
|
||||
|
||||
test("resolves primitive alias @string", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
const envelope = await wrapEnvelope(store, "@string", "hello");
|
||||
|
||||
expect(envelope.type).toBe(aliases["@string"]);
|
||||
expect(envelope.value).toBe("hello");
|
||||
});
|
||||
|
||||
test("throws for unknown alias", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
await expect(
|
||||
wrapEnvelope(store, "@output/nonexistent", "value"),
|
||||
).rejects.toThrow("Unknown schema alias: @output/nonexistent");
|
||||
});
|
||||
|
||||
test("is idempotent — same alias returns same type hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
|
||||
const first = await wrapEnvelope(store, "@output/verify", "ok");
|
||||
const second = await wrapEnvelope(store, "@output/verify", "corrupted");
|
||||
|
||||
expect(first.type).toBe(second.type);
|
||||
expect(first.value).toBe("ok");
|
||||
expect(second.value).toBe("corrupted");
|
||||
});
|
||||
|
||||
test("preserves complex object values without mutation", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const original = {
|
||||
name: "test",
|
||||
schema: "AAAAAAAAAAAAA",
|
||||
value: "BBBBBBBBBBBBB",
|
||||
created: 1000,
|
||||
updated: 2000,
|
||||
tags: { env: "prod" },
|
||||
labels: ["stable"],
|
||||
};
|
||||
const envelope = await wrapEnvelope(store, "@output/var-set", original);
|
||||
|
||||
expect(envelope.value).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
|
||||
/**
|
||||
* Resolve a schema alias (e.g. "@output/put") to its hash via bootstrap,
|
||||
* then return a typed envelope ready for store.put() or direct rendering.
|
||||
*/
|
||||
export async function wrapEnvelope(
|
||||
store: Store,
|
||||
schemaAlias: string,
|
||||
value: unknown,
|
||||
): Promise<{ type: Hash; value: unknown }> {
|
||||
const aliases = await bootstrap(store);
|
||||
const typeHash = aliases[schemaAlias];
|
||||
if (typeHash === undefined) {
|
||||
throw new Error(`Unknown schema alias: ${schemaAlias}`);
|
||||
}
|
||||
return { type: typeHash, value };
|
||||
}
|
||||
Reference in New Issue
Block a user