Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 038c901f4b | |||
| f4cf92e128 | |||
| c8bf38cb81 | |||
| b93d7b229a |
@@ -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,28 +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:
|
||||
init Create store dir and write bootstrap seed
|
||||
bootstrap Write meta-schema seed, print hash
|
||||
schema put <file.json> Register schema, print type hash
|
||||
schema get <type-hash> Print schema JSON
|
||||
schema list List all schemas (name + hash)
|
||||
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,264 +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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Store validation - non-existent paths (Issue #55)", () => {
|
||||
test("E2E-55-1: get with non-existent store reports store not found", async () => {
|
||||
const fakePath = "/nonexistent/path/to/store";
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["get", "AAAAAAAAAAAAA"],
|
||||
fakePath,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
expect(stderr).toContain(fakePath);
|
||||
expect(stderr).not.toContain("Node not found");
|
||||
});
|
||||
|
||||
test("E2E-55-2: has with non-existent store reports store not found", async () => {
|
||||
const fakePath = `/tmp/nonexistent-store-${Date.now()}`;
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["has", "BBBBBBBBBBBBB"],
|
||||
fakePath,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-3: verify with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["verify", "CCCCCCCCCCCCC"],
|
||||
"/does/not/exist",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-4: schema get with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["schema", "get", "@string"],
|
||||
"/fake/path",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-5: schema list with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["schema", "list"],
|
||||
"/nonexistent/store",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-6: walk with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["walk", "DDDDDDDDDDDDD"],
|
||||
"/tmp/missing-store",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-7: cat with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["cat", "EEEEEEEEEEEEE"],
|
||||
"/no/such/dir",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-8: refs with non-existent store reports store not found", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["refs", "FFFFFFFFFFFFF"],
|
||||
"/nonexistent",
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
});
|
||||
|
||||
test("E2E-55-9: existing store works normally (regression)", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const payloadFile = join(tmpStore, "test.json");
|
||||
writeFileSync(payloadFile, JSON.stringify("test"));
|
||||
|
||||
const { stdout: hash } = await runCli(
|
||||
["put", "@string", payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
const { exitCode, stdout, stderr } = await runCli(
|
||||
["get", hash.trim()],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
const node = JSON.parse(stdout);
|
||||
expect(node).toHaveProperty("type");
|
||||
expect(node).toHaveProperty("payload", "test");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("E2E-55-10: init creates directory (should not validate)", async () => {
|
||||
const newStore = join(tmpdir(), `new-store-${Date.now()}`);
|
||||
try {
|
||||
const { exitCode, stdout, stderr } = await runCli(["init"], newStore);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
const { existsSync } = await import("node:fs");
|
||||
expect(existsSync(newStore)).toBe(true);
|
||||
} finally {
|
||||
rmSync(newStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
// 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,13 +57,7 @@ function stripVolatile(json: string): unknown {
|
||||
// ---- Phase 1: CAS Core ----
|
||||
|
||||
describe("Phase 1: CAS Core", () => {
|
||||
test("1.1 bootstrap returns 13-char Base32 hash", async () => {
|
||||
const { stdout, exitCode } = await runCli(["init"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("1.2 schema put returns type hash", async () => {
|
||||
test("1.1 init + put with @object bootstraps store", async () => {
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
@@ -77,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 () => {
|
||||
@@ -148,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,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 () => {
|
||||
@@ -510,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);
|
||||
@@ -531,14 +496,15 @@ describe("Phase 7: Edge Cases", () => {
|
||||
expect(combined.toLowerCase()).toContain("usage");
|
||||
});
|
||||
|
||||
test("7.7 --store non-existent path errors with Store not found", async () => {
|
||||
const fakeStore = "/nonexistent/store/path";
|
||||
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",
|
||||
fakeStore,
|
||||
fileAsStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"get",
|
||||
@@ -549,7 +515,6 @@ describe("Phase 7: Edge Cases", () => {
|
||||
const exitCode = await proc.exited;
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Store not found");
|
||||
expect(stderr).not.toContain("Node not found");
|
||||
expect(stderr).toContain("not a directory");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
|
||||
@@ -17,14 +17,13 @@ import {
|
||||
refs,
|
||||
renderAsync,
|
||||
renderDirect,
|
||||
SchemaValidationError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
validate,
|
||||
verify,
|
||||
walk,
|
||||
} from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
// ---- Argument parsing ----
|
||||
|
||||
@@ -41,6 +40,7 @@ const VALUE_FLAGS = new Set([
|
||||
"decay",
|
||||
"epsilon",
|
||||
"inline",
|
||||
"type",
|
||||
]);
|
||||
|
||||
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
@@ -113,26 +113,16 @@ function readJsonFile(file: string): unknown {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the store directory exists.
|
||||
* Dies with a clear error message if not found.
|
||||
* Open the filesystem-backed CAS store.
|
||||
* Automatically creates directory and bootstraps if needed.
|
||||
*/
|
||||
function validateStoreExists(path: string): void {
|
||||
if (!existsSync(path)) {
|
||||
die(`Store not found at ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openStore(shouldCreate = false): Store {
|
||||
async function openStore(): Promise<Store> {
|
||||
const fullPath = resolve(storePath);
|
||||
if (!shouldCreate) {
|
||||
validateStoreExists(fullPath);
|
||||
}
|
||||
return createFsStore(fullPath);
|
||||
return await openFsStore(fullPath);
|
||||
}
|
||||
|
||||
function openVarStore(): VariableStore {
|
||||
const store = openStore(true);
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
async function openVarStore(): Promise<VariableStore> {
|
||||
const store = await openStore();
|
||||
return createVariableStore(resolve(varDbPath), store);
|
||||
}
|
||||
|
||||
@@ -141,12 +131,9 @@ function openVarStore(): VariableStore {
|
||||
* If the input starts with @, resolve it via bootstrap
|
||||
* Otherwise, return the hash as-is
|
||||
*/
|
||||
async function resolveTypeHash(
|
||||
typeHashOrAlias: string,
|
||||
shouldCreate = false,
|
||||
): Promise<Hash> {
|
||||
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
|
||||
if (typeHashOrAlias.startsWith("@")) {
|
||||
const store = openStore(shouldCreate);
|
||||
const store = await openStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const resolvedHash = builtinSchemas[typeHashOrAlias];
|
||||
if (!resolvedHash) {
|
||||
@@ -162,7 +149,7 @@ async function resolveTypeHash(
|
||||
* This is the type hash used in JSON envelopes
|
||||
*/
|
||||
async function getVariableSchemaHash(): Promise<Hash> {
|
||||
const store = openStore();
|
||||
const store = await openStore();
|
||||
|
||||
// Define the Variable JSON Schema (updated for new model with composite key)
|
||||
const variableSchema: JSONSchema = {
|
||||
@@ -240,85 +227,14 @@ function parseTagsLabels(args: string[]): {
|
||||
|
||||
// ---- Commands ----
|
||||
|
||||
async function cmdInit(): Promise<void> {
|
||||
const dir = resolve(storePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const store = createFsStore(dir);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
console.log(metaHash);
|
||||
}
|
||||
|
||||
async function cmdBootstrap(): Promise<void> {
|
||||
const store = openStore(true);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
console.log(metaHash);
|
||||
}
|
||||
|
||||
async function cmdSchemaPut(args: string[]): Promise<void> {
|
||||
const file = args[0];
|
||||
if (!file) die("Usage: json-cas schema put <file.json>");
|
||||
const schema = readJsonFile(file) as JSONSchema;
|
||||
const store = openStore(true);
|
||||
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 = openStore();
|
||||
const schema = getSchema(store, hash);
|
||||
if (schema === null) die(`Schema not found: ${hashOrAlias}`);
|
||||
out(schema);
|
||||
}
|
||||
|
||||
async function cmdSchemaList(): Promise<void> {
|
||||
const store = 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 = 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];
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas put <type-hash> <file.json>");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
const payload = readJsonFile(file);
|
||||
const store = openStore(true);
|
||||
const store = await openStore();
|
||||
|
||||
// Check if schema exists
|
||||
const schema = getSchema(store, typeHash);
|
||||
@@ -343,7 +259,7 @@ async function cmdPut(args: string[]): Promise<void> {
|
||||
async function cmdGet(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas get <hash>");
|
||||
const store = openStore();
|
||||
const store = await openStore();
|
||||
const node = store.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
out(node);
|
||||
@@ -352,24 +268,29 @@ async function cmdGet(args: string[]): Promise<void> {
|
||||
async function cmdHas(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas has <hash>");
|
||||
const store = openStore();
|
||||
const store = await openStore();
|
||||
console.log(String(store.has(hash)));
|
||||
}
|
||||
|
||||
async function cmdVerify(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas verify <hash>");
|
||||
const store = openStore();
|
||||
const store = await openStore();
|
||||
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> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas refs <hash>");
|
||||
const store = openStore();
|
||||
const store = await openStore();
|
||||
const node = store.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
const refHashes = refs(store, node);
|
||||
@@ -381,7 +302,7 @@ async function cmdRefs(args: string[]): Promise<void> {
|
||||
async function cmdWalk(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas walk <hash> [--format tree]");
|
||||
const store = openStore();
|
||||
const store = await openStore();
|
||||
const format = flags.format;
|
||||
|
||||
if (format === "tree") {
|
||||
@@ -422,7 +343,7 @@ async function cmdHash(args: string[]): Promise<void> {
|
||||
const file = args[1];
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas hash <type-hash> <file.json>");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
const payload = readJsonFile(file);
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
console.log(hash);
|
||||
@@ -442,7 +363,7 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
const store = await openStore();
|
||||
|
||||
// Parse numeric options
|
||||
const resolution =
|
||||
@@ -517,7 +438,7 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
);
|
||||
process.stdout.write(output);
|
||||
} else {
|
||||
const varStore = openVarStore();
|
||||
const varStore = await openVarStore();
|
||||
const output = await renderAsync(store, hash, {
|
||||
resolution,
|
||||
decay,
|
||||
@@ -538,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 = 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];
|
||||
@@ -560,7 +468,7 @@ async function cmdVarSet(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
const varStore = await openVarStore();
|
||||
|
||||
try {
|
||||
// Parse tags/labels from --tag flags
|
||||
@@ -611,7 +519,7 @@ async function cmdVarGet(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var get <name> --schema <hash>");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
const varStore = await openVarStore();
|
||||
|
||||
try {
|
||||
const variable = varStore.get(name, schema);
|
||||
@@ -633,7 +541,7 @@ async function cmdVarDelete(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var delete <name> [--schema <hash>]");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
const varStore = await openVarStore();
|
||||
|
||||
try {
|
||||
if (schema !== undefined) {
|
||||
@@ -670,7 +578,7 @@ async function cmdVarTag(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
const varStore = await openVarStore();
|
||||
|
||||
try {
|
||||
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
|
||||
@@ -702,7 +610,7 @@ async function cmdVarList(args: string[]): Promise<void> {
|
||||
const schema = flags.schema as string | undefined;
|
||||
const tagFlags = flags.tag;
|
||||
|
||||
const varStore = openVarStore();
|
||||
const varStore = await openVarStore();
|
||||
|
||||
try {
|
||||
// Parse tags/labels from --tag flags
|
||||
@@ -744,8 +652,7 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
@@ -816,8 +723,7 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas template get <schema-hash>");
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
@@ -843,8 +749,7 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdTemplateList(_args: string[]): Promise<void> {
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
@@ -884,8 +789,7 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas template delete <schema-hash>");
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
@@ -905,7 +809,7 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdGc(_args: string[]): Promise<void> {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(varDbPath, store);
|
||||
|
||||
try {
|
||||
@@ -916,27 +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:
|
||||
init Create store dir and write bootstrap seed
|
||||
bootstrap Write meta-schema seed, print hash
|
||||
schema put <file.json> Register schema, print type hash
|
||||
schema get <type-hash> Print schema JSON
|
||||
schema list List all schemas (name + hash)
|
||||
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)
|
||||
@@ -971,35 +880,6 @@ if (!cmd) {
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case "init":
|
||||
await cmdInit();
|
||||
break;
|
||||
|
||||
case "bootstrap":
|
||||
await cmdBootstrap();
|
||||
break;
|
||||
|
||||
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;
|
||||
@@ -1032,8 +912,8 @@ switch (cmd) {
|
||||
await cmdRender(rest);
|
||||
break;
|
||||
|
||||
case "cat":
|
||||
await cmdCat(rest);
|
||||
case "list":
|
||||
await cmdList(rest);
|
||||
break;
|
||||
|
||||
case "var": {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { createFsStore } from "./store.js";
|
||||
export { createFsStore, openStore } from "./store.js";
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasNode } from "@uncaged/json-cas";
|
||||
@@ -10,7 +16,7 @@ import {
|
||||
verify,
|
||||
} from "@uncaged/json-cas";
|
||||
|
||||
import { createFsStore } from "./store.js";
|
||||
import { createFsStore, openStore } from "./store.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "json-cas-fs-test-"));
|
||||
@@ -312,3 +318,114 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// openStore – async with auto-bootstrap
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("openStore – async with auto-bootstrap", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = makeTmpDir();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("openStore returns Promise<Store>", async () => {
|
||||
const store = await openStore(dir);
|
||||
expect(store).toBeDefined();
|
||||
expect(typeof store.put).toBe("function");
|
||||
expect(typeof store.get).toBe("function");
|
||||
});
|
||||
|
||||
test("openStore auto-creates directory when it doesn't exist", async () => {
|
||||
const nested = join(dir, "sub", "nested", "store");
|
||||
expect(existsSync(nested)).toBe(false);
|
||||
|
||||
const store = await openStore(nested);
|
||||
expect(existsSync(nested)).toBe(true);
|
||||
|
||||
// Verify store works
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
const hash = await store.put(typeHash, { x: 1 });
|
||||
expect(store.has(hash)).toBe(true);
|
||||
});
|
||||
|
||||
test("openStore works when directory already exists", async () => {
|
||||
// Pre-create the directory
|
||||
const store1 = await openStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
await store1.put(typeHash, { x: 1 });
|
||||
|
||||
// Open again
|
||||
const store2 = await openStore(dir);
|
||||
expect(store2.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("openStore throws error when path exists but is not a directory", async () => {
|
||||
const filePath = join(dir, "not-a-dir");
|
||||
writeFileSync(filePath, "test");
|
||||
|
||||
await expect(openStore(filePath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("openStore auto-bootstraps on first open (empty directory)", async () => {
|
||||
const store = await openStore(dir);
|
||||
|
||||
// Check that bootstrap schemas exist
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
|
||||
expect(metaHash).toBeDefined();
|
||||
expect(store.has(metaHash as string)).toBe(true);
|
||||
|
||||
// Verify all core schemas exist
|
||||
expect(store.has(builtinSchemas["@string"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@number"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@object"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@array"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@bool"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@schema"] as string)).toBe(true);
|
||||
});
|
||||
|
||||
test("openStore bootstrap is idempotent on subsequent opens", async () => {
|
||||
const store1 = await openStore(dir);
|
||||
const schemas1 = await bootstrap(store1);
|
||||
const count1 = store1.listAll().length;
|
||||
|
||||
const store2 = await openStore(dir);
|
||||
const schemas2 = await bootstrap(store2);
|
||||
const count2 = store2.listAll().length;
|
||||
|
||||
// Same schemas, same count
|
||||
expect(schemas1).toEqual(schemas2);
|
||||
expect(count1).toBe(count2);
|
||||
});
|
||||
|
||||
test("openStore works on already-bootstrapped store", async () => {
|
||||
// Bootstrap manually first
|
||||
const store1 = createFsStore(dir);
|
||||
const schemas1 = await bootstrap(store1);
|
||||
|
||||
// Open with openStore
|
||||
const store2 = await openStore(dir);
|
||||
const schemas2 = await bootstrap(store2);
|
||||
|
||||
expect(schemas1).toEqual(schemas2);
|
||||
});
|
||||
|
||||
test("openStore auto-bootstraps old store without bootstrap", async () => {
|
||||
// Create a store with some data but no bootstrap
|
||||
const store1 = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "custom" });
|
||||
await store1.put(typeHash, { data: "old" });
|
||||
|
||||
// Open with openStore - should auto-bootstrap
|
||||
const store2 = await openStore(dir);
|
||||
const schemas = await bootstrap(store2);
|
||||
|
||||
expect(store2.has(schemas["@schema"] as string)).toBe(true);
|
||||
// Old data still exists
|
||||
expect(store2.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -13,6 +14,7 @@ import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
|
||||
|
||||
import {
|
||||
BOOTSTRAP_STORE,
|
||||
bootstrap,
|
||||
cborEncode,
|
||||
computeHash,
|
||||
computeSelfHash,
|
||||
@@ -219,3 +221,57 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem-backed CAS store with automatic directory creation and bootstrap.
|
||||
* This is an async function that:
|
||||
* 1. Creates the directory (with recursive: true) if it doesn't exist
|
||||
* 2. Validates that the path is actually a directory (not a file)
|
||||
* 3. Creates the store
|
||||
* 4. Runs bootstrap (which is idempotent)
|
||||
*
|
||||
* @param dir - The directory path for the store
|
||||
* @returns A Promise resolving to the BootstrapCapableStore
|
||||
* @throws Error if the path exists but is not a directory
|
||||
*/
|
||||
export async function openStore(dir: string): Promise<BootstrapCapableStore> {
|
||||
// Create directory if it doesn't exist
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === "EACCES") {
|
||||
throw new Error(`Permission denied: cannot access store at ${dir}`);
|
||||
}
|
||||
if (nodeError.code === "ENOTDIR") {
|
||||
throw new Error(`Path exists but is not a directory: ${dir}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Validate that the path is a directory
|
||||
try {
|
||||
const stats = statSync(dir);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path exists but is not a directory: ${dir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === "ENOENT") {
|
||||
throw new Error(`Store not found at ${dir}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create the store
|
||||
const store = createFsStore(dir);
|
||||
|
||||
// Bootstrap (idempotent)
|
||||
await bootstrap(store);
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user