Compare commits

...

4 Commits

Author SHA1 Message Date
xiaoju 22fce0ac66 feat: add built-in schema aliases with @ prefix support
Implements Phase 1 of issue #37:
- Extended variable name validation to allow @ prefix (system-reserved)
- Registered 6 built-in schemas with @ aliases during bootstrap
  - @schema → meta-schema (self-referential)
  - @string → { type: "string" }
  - @number → { type: "number" }
  - @object → { type: "object" }
  - @array → { type: "array" }
  - @bool → { type: "boolean" }
- Bootstrap now returns Record<string, Hash> instead of Hash
- Added CLI @ alias resolution for all commands accepting type-hash
  - ucas schema get @string
  - ucas put @string <file>
  - ucas hash @string <file>
- Added comprehensive test coverage for all features
  - Variable name validation with @ prefix
  - Built-in schema registration
  - CLI alias resolution
  - Integration tests

Fixes #37

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 04:18:46 +00:00
xiaoju fddbb1549e feat: RFC-31 Phase 3 — CLI var 子命令重写
- var set <name> <hash> (upsert, replaces create+update)
- var get <name> --schema <hash> (schema required)
- var delete <name> [--schema <hash>] (optional schema)
- var list [prefix] (replaces --scope)
- var tag <name> --schema <hash> ...
- 41 CLI tests, all passing

Fixes #34
Ref #31
2026-05-30 15:44:19 +00:00
xiaoju 109aaab9b8 feat: RFC-31 Phase 3 — rewrite CLI var subcommands for composite key model
Migrate CLI var subcommands from ULID ID model to (name, schema) composite key model.

- Replace var create/update with unified var set (upsert semantics)
- Update var get to require --schema parameter for precise query
- Enhance var delete with batch (no --schema) and precise (with --schema) modes
- Refactor var list to use positional prefix parameter
- Update var tag to target composite keys
- Add comprehensive test suite (41 tests, 100% coverage)
- Update Variable schema: remove id/scope, add name field

Fixes #34

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 14:29:33 +00:00
xiaoju 906a6dfd1c feat: RFC-31 Phase 1+2 — Variable model refactor with (name, schema) composite key
- Replace ULID id + scope with qualified name + schema composite PK
- Add set() upsert, remove() with optional schema, validateName()
- get(name, schema) with fixed return type (no polymorphic)
- Tags/labels adapted to composite foreign keys
- GC compatible

Fixes #32
Ref #31
2026-05-30 13:36:51 +00:00
12 changed files with 1794 additions and 147 deletions
+217 -2
View File
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test";
import { resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const pkgPath = resolve(import.meta.dir, "../package.json");
@@ -31,3 +33,216 @@ describe("ucas command alias", () => {
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
});
});
// ---- @ Alias Resolution Tests ----
let testDir: string;
let storePath: string;
let cliPath: string;
beforeEach(() => {
// Create unique temp directory for each test
testDir = join(
tmpdir(),
`json-cas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
// Clean up test directory
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
/**
* Run CLI command and return stdout, stderr, and exit code
*/
async function runCli(...args: string[]): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--store", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
}
describe("@ Alias Resolution - schema get", () => {
test("ucas schema get @string should work", async () => {
await runCli("init"); // Initialize store
const { stdout, stderr, exitCode } = await runCli(
"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 runCli("init");
const { stdout, exitCode } = await runCli("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 runCli("init");
const { stdout, exitCode } = await runCli("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 runCli("init");
const { stdout, exitCode } = await runCli("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 runCli("init");
const { stdout, exitCode } = await runCli("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 runCli("init");
const { stdout, exitCode } = await runCli("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 runCli("init");
const { stderr, exitCode } = await runCli("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 runCli("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify("hello world"));
const { stdout, stderr, exitCode } = await runCli(
"put",
"@string",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
// Should output a valid hash (13 chars)
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @number <file> should resolve alias", async () => {
await runCli("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "42");
const { stdout, exitCode } = await runCli("put", "@number", payloadFile);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @object <file> should resolve alias", async () => {
await runCli("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ foo: "bar" }));
const { stdout, exitCode } = await runCli("put", "@object", payloadFile);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @invalid <file> should fail", async () => {
await runCli("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "{}");
const { stderr, exitCode } = await runCli("put", "@invalid", payloadFile);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
});
});
describe("@ Alias Resolution - hash", () => {
test("ucas hash @string <file> should compute hash without storing", async () => {
await runCli("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify("test"));
const { stdout, stderr, exitCode } = await runCli(
"hash",
"@string",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
+112 -96
View File
@@ -11,11 +11,10 @@ import {
createVariableStore,
gc,
getSchema,
InvalidScopeError,
InvalidTagFormatError,
InvalidVariableNameError,
putSchema,
refs,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
validate,
@@ -29,14 +28,7 @@ import { createFsStore } from "@uncaged/json-cas-fs";
type Flags = Record<string, string | boolean | string[]>;
/** Flags that consume the next token as their value. All others are boolean. */
const VALUE_FLAGS = new Set([
"store",
"format",
"scope",
"value",
"var-db",
"tag",
]);
const VALUE_FLAGS = new Set(["store", "format", "var-db", "tag", "schema"]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
const flags: Flags = {};
@@ -117,6 +109,24 @@ function openVarStore(): VariableStore {
return createVariableStore(resolve(varDbPath), store);
}
/**
* Resolve a type-hash, handling @ aliases
* If the input starts with @, resolve it via bootstrap
* Otherwise, return the hash as-is
*/
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
if (typeHashOrAlias.startsWith("@")) {
const store = openStore();
const builtinSchemas = await bootstrap(store);
const resolvedHash = builtinSchemas[typeHashOrAlias];
if (!resolvedHash) {
die(`Schema not found: ${typeHashOrAlias}`);
}
return resolvedHash;
}
return typeHashOrAlias;
}
/**
* Get the Variable schema's CAS hash
* This is the type hash used in JSON envelopes
@@ -124,25 +134,23 @@ function openVarStore(): VariableStore {
async function getVariableSchemaHash(): Promise<Hash> {
const store = openStore();
// Define the Variable JSON Schema (simple version for envelope)
// Define the Variable JSON Schema (updated for new model with composite key)
const variableSchema: JSONSchema = {
title: "Variable",
type: "object",
properties: {
id: { type: "string" },
scope: { type: "string" },
value: { type: "string" },
name: { type: "string" },
schema: { type: "string" },
value: { type: "string" },
created: { type: "number" },
updated: { type: "number" },
tags: { type: "object" },
labels: { type: "array", items: { type: "string" } },
},
required: [
"id",
"scope",
"value",
"name",
"schema",
"value",
"created",
"updated",
"tags",
@@ -206,14 +214,16 @@ async function cmdInit(): Promise<void> {
const dir = resolve(storePath);
mkdirSync(dir, { recursive: true });
const store = createFsStore(dir);
const hash = await bootstrap(store);
console.log(hash);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
console.log(metaHash);
}
async function cmdBootstrap(): Promise<void> {
const store = openStore();
const hash = await bootstrap(store);
console.log(hash);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
console.log(metaHash);
}
async function cmdSchemaPut(args: string[]): Promise<void> {
@@ -226,17 +236,20 @@ async function cmdSchemaPut(args: string[]): Promise<void> {
}
async function cmdSchemaGet(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas schema get <type-hash>");
const hashOrAlias = args[0];
if (!hashOrAlias) die("Usage: json-cas schema get <type-hash>");
const hash = await resolveTypeHash(hashOrAlias);
const store = openStore();
const schema = getSchema(store, hash);
if (schema === null) die(`Schema not found: ${hash}`);
if (schema === null) die(`Schema not found: ${hashOrAlias}`);
out(schema);
}
async function cmdSchemaList(): Promise<void> {
const store = openStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (!metaHash) throw new Error("Meta-schema not found");
for (const hash of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
@@ -262,9 +275,11 @@ async function cmdSchemaValidate(args: string[]): Promise<void> {
}
async function cmdPut(args: string[]): Promise<void> {
const typeHash = args[0];
const typeHashOrAlias = args[0];
const file = args[1];
if (!typeHash || !file) die("Usage: json-cas put <type-hash> <file.json>");
if (!typeHashOrAlias || !file)
die("Usage: json-cas put <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = readJsonFile(file);
const store = openStore();
const hash = await store.put(typeHash, payload);
@@ -349,9 +364,11 @@ async function cmdWalk(args: string[]): Promise<void> {
}
async function cmdHash(args: string[]): Promise<void> {
const typeHash = args[0];
const typeHashOrAlias = args[0];
const file = args[1];
if (!typeHash || !file) die("Usage: json-cas hash <type-hash> <file.json>");
if (!typeHashOrAlias || !file)
die("Usage: json-cas hash <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = readJsonFile(file);
const hash = await computeHash(typeHash, payload);
console.log(hash);
@@ -370,13 +387,14 @@ async function cmdCat(args: string[]): Promise<void> {
}
}
async function cmdVarCreate(_args: string[]): Promise<void> {
const scope = flags.scope as string | undefined;
const value = flags.value as string | undefined;
async function cmdVarSet(args: string[]): Promise<void> {
const name = args[0];
const value = args[1];
const tagFlags = flags.tag;
if (!scope) die("Usage: json-cas var create --scope <scope> --value <hash>");
if (!value) die("Usage: json-cas var create --scope <scope> --value <hash>");
if (!name || !value) {
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
}
const varStore = openVarStore();
@@ -391,18 +409,25 @@ async function cmdVarCreate(_args: string[]): Promise<void> {
// Check for conflicts in initial tags/labels
if (deleteNames.length > 0) {
die("Error: Cannot use deletion syntax (:name) in var create");
die("Error: Cannot use deletion syntax (:name) in var set");
}
const variable = varStore.create(scope, value, {
tags: Object.keys(tags).length > 0 ? tags : undefined,
labels: labels.length > 0 ? labels : undefined,
});
// If --tag flags are provided at all, always pass options to replace tags/labels
// If no --tag flags, pass undefined to preserve existing tags/labels
const options =
tagArgs.length > 0
? {
tags: Object.keys(tags).length > 0 ? tags : {},
labels: labels.length > 0 ? labels : [],
}
: undefined;
const variable = varStore.set(name, value, options);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof InvalidScopeError ||
e instanceof InvalidVariableNameError ||
e instanceof CasNodeNotFoundError ||
e instanceof TagLabelConflictError
) {
@@ -415,15 +440,19 @@ async function cmdVarCreate(_args: string[]): Promise<void> {
}
async function cmdVarGet(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var get <id>");
const name = args[0];
const schema = flags.schema as string | undefined;
if (!name || !schema) {
die("Usage: json-cas var get <name> --schema <hash>");
}
const varStore = openVarStore();
try {
const variable = varStore.get(id);
const variable = varStore.get(name, schema);
if (variable === null) {
die(`Error: Variable not found: ${id}`);
die(`Error: Variable not found: name=${name}, schema=${schema}`);
}
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
@@ -432,44 +461,28 @@ async function cmdVarGet(args: string[]): Promise<void> {
}
}
async function cmdVarUpdate(args: string[]): Promise<void> {
const id = args[0];
const value = args[1];
if (!id || !value) {
die("Usage: json-cas var update <id> <hash>");
}
const varStore = openVarStore();
try {
const variable = varStore.update(id, value);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
e instanceof SchemaMismatchError ||
e instanceof CasNodeNotFoundError
) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarDelete(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var delete <id>");
const name = args[0];
const schema = flags.schema as string | undefined;
if (!name) {
die("Usage: json-cas var delete <name> [--schema <hash>]");
}
const varStore = openVarStore();
try {
const variable = varStore.delete(id);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
if (schema !== undefined) {
// Precise deletion: remove specific (name, schema) variant
const variable = varStore.remove(name, schema);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} else {
// Batch deletion: remove all variants for this name
const variables = varStore.remove(name);
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
}
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: ${e.message}`);
@@ -481,12 +494,16 @@ async function cmdVarDelete(args: string[]): Promise<void> {
}
async function cmdVarTag(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var tag <id> <tag>...");
const name = args[0];
const schema = flags.schema as string | undefined;
if (!name || !schema) {
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const tagArgs = args.slice(1);
if (tagArgs.length === 0) {
die("Usage: json-cas var tag <id> <tag>...");
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const varStore = openVarStore();
@@ -494,7 +511,7 @@ async function cmdVarTag(args: string[]): Promise<void> {
try {
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
const variable = varStore.tag(id, {
const variable = varStore.tag(name, schema, {
add: Object.keys(tags).length > 0 ? tags : undefined,
addLabels: labels.length > 0 ? labels : undefined,
delete: deleteNames.length > 0 ? deleteNames : undefined,
@@ -516,8 +533,9 @@ async function cmdVarTag(args: string[]): Promise<void> {
}
}
async function cmdVarList(_args: string[]): Promise<void> {
const scope = (flags.scope as string | undefined) ?? "";
async function cmdVarList(args: string[]): Promise<void> {
const namePrefix = args[0] ?? "";
const schema = flags.schema as string | undefined;
const tagFlags = flags.tag;
const varStore = openVarStore();
@@ -537,14 +555,15 @@ async function cmdVarList(_args: string[]): Promise<void> {
}
const variables = varStore.list({
scope,
namePrefix,
schema,
tags: Object.keys(tags).length > 0 ? tags : undefined,
labels: labels.length > 0 ? labels : undefined,
});
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
} catch (e) {
if (e instanceof InvalidScopeError) {
if (e instanceof InvalidVariableNameError) {
die(`Error: ${e.message}`);
}
throw e;
@@ -584,18 +603,18 @@ Commands:
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
var create --scope <s> --value <h> [--tag <tag>...] Create a variable
var get <id> Get a variable by ID
var update <id> <hash> Update variable value
var delete <id> Delete a variable
var tag <id> <tag>... Add/update/delete tags and labels
var list [--scope <prefix>] [--tag <tag>...] List variables (filter by scope/tags/labels)
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
var delete <name> [--schema <hash>] Delete variable(s)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables
var tag <name> --schema <hash> <operations...> Modify tags/labels
gc Run garbage collection
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)`);
}
@@ -673,15 +692,12 @@ switch (cmd) {
case "var": {
const [sub, ...subRest] = rest;
switch (sub) {
case "create":
await cmdVarCreate(subRest);
case "set":
await cmdVarSet(subRest);
break;
case "get":
await cmdVarGet(subRest);
break;
case "update":
await cmdVarUpdate(subRest);
break;
case "delete":
await cmdVarDelete(subRest);
break;
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -43,7 +43,8 @@ describe("createFsStore – init and bootstrap", () => {
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
const store = createFsStore(dir);
const hash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const hash = builtinSchemas["@schema"] ?? "";
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
@@ -57,8 +58,8 @@ describe("createFsStore – init and bootstrap", () => {
const h1 = await bootstrap(store);
const h2 = await bootstrap(store);
expect(h1).toBe(h2);
expect(store.listByType(h1)).toHaveLength(1);
expect(h1).toEqual(h2);
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
});
});
@@ -104,7 +105,8 @@ describe("createFsStore – persistence round-trip", () => {
test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => {
const store1 = createFsStore(dir);
const hash = await bootstrap(store1);
const builtinSchemas = await bootstrap(store1);
const hash = builtinSchemas["@schema"] ?? "";
const store2 = createFsStore(dir);
const node = store2.get(hash) as CasNode;
@@ -251,10 +253,11 @@ describe("createFsStore – listByType", () => {
test("bootstrap node is listed under its self type after reload", async () => {
const store1 = createFsStore(dir);
const hash = await bootstrap(store1);
const builtinSchemas = await bootstrap(store1);
const hash = builtinSchemas["@schema"] ?? "";
const store2 = createFsStore(dir);
expect(store2.listByType(hash)).toEqual([hash]);
expect(store2.listByType(hash)).toContain(hash);
});
});
@@ -284,7 +287,8 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
test("verify passes on a disk-loaded bootstrap node", async () => {
const store1 = createFsStore(dir);
const hash = await bootstrap(store1);
const builtinSchemas = await bootstrap(store1);
const hash = builtinSchemas["@schema"] ?? "";
const store2 = createFsStore(dir);
const node = store2.get(hash) as CasNode;
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { getSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
// ──────────────────────────────────────────────────────────────────────────────
// Built-in Schema Registration Tests
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
// Should return object with 6 aliases
expect(builtinSchemas).toHaveProperty("@schema");
expect(builtinSchemas).toHaveProperty("@string");
expect(builtinSchemas).toHaveProperty("@number");
expect(builtinSchemas).toHaveProperty("@object");
expect(builtinSchemas).toHaveProperty("@array");
expect(builtinSchemas).toHaveProperty("@bool");
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
expect(typeof hash).toBe("string");
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("should register @schema as meta-schema alias", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (!metaHash) throw new Error("@schema not found");
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
expect(metaSchema?.type).toBe("object");
expect(metaSchema?.description).toBe("json-cas JSON Schema meta-schema");
});
test("should register @string schema correctly", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const stringHash = builtinSchemas["@string"];
if (!stringHash) throw new Error("@string not found");
const stringSchema = getSchema(store, stringHash);
expect(stringSchema).toEqual({ type: "string" });
});
test("should register @number schema correctly", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const numberHash = builtinSchemas["@number"];
if (!numberHash) throw new Error("@number not found");
const numberSchema = getSchema(store, numberHash);
expect(numberSchema).toEqual({ type: "number" });
});
test("should register @object schema correctly", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const objectHash = builtinSchemas["@object"];
if (!objectHash) throw new Error("@object not found");
const objectSchema = getSchema(store, objectHash);
expect(objectSchema).toEqual({ type: "object" });
});
test("should register @array schema correctly", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const arrayHash = builtinSchemas["@array"];
if (!arrayHash) throw new Error("@array not found");
const arraySchema = getSchema(store, arrayHash);
expect(arraySchema).toEqual({ type: "array" });
});
test("should register @bool schema correctly", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const boolHash = builtinSchemas["@bool"];
if (!boolHash) throw new Error("@bool not found");
const boolSchema = getSchema(store, boolHash);
expect(boolSchema).toEqual({ type: "boolean" });
});
test("should return same hashes on repeated bootstrap calls", async () => {
const store = createMemoryStore();
const first = await bootstrap(store);
const second = await bootstrap(store);
expect(first).toEqual(second);
// Verify each alias points to same hash
expect(first["@string"]).toBe(second["@string"]);
expect(first["@number"]).toBe(second["@number"]);
expect(first["@object"]).toBe(second["@object"]);
expect(first["@array"]).toBe(second["@array"]);
expect(first["@bool"]).toBe(second["@bool"]);
expect(first["@schema"]).toBe(second["@schema"]);
});
test("all built-in schemas should be typed by meta-schema", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (!metaHash) throw new Error("@schema not found");
for (const [alias, hash] of Object.entries(builtinSchemas)) {
if (alias === "@schema") continue; // meta-schema is self-typed
const node = store.get(hash);
expect(node).not.toBeNull();
expect(node?.type).toBe(metaHash);
}
});
});
+24 -5
View File
@@ -64,13 +64,32 @@ const BOOTSTRAP_PAYLOAD = {
} as const;
/**
* Write the meta-schema seed node into the store.
* The returned hash equals the node's own type field (self-referencing).
* Idempotent: calling bootstrap multiple times returns the same hash.
* Write the meta-schema seed node into the store and register built-in schemas.
* The returned object contains aliases for the meta-schema and 5 primitive schemas.
* Idempotent: calling bootstrap multiple times returns the same hashes.
*/
export async function bootstrap(store: Store): Promise<Hash> {
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
if (!isBootstrapCapableStore(store)) {
throw new Error("Store does not support bootstrap");
}
return store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
// 1. Bootstrap the meta-schema (self-referential)
const metaHash = await store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
// 2. Register built-in primitive schemas directly (without putSchema to avoid recursion)
const stringHash = await store.put(metaHash, { type: "string" });
const numberHash = await store.put(metaHash, { type: "number" });
const objectHash = await store.put(metaHash, { type: "object" });
const arrayHash = await store.put(metaHash, { type: "array" });
const boolHash = await store.put(metaHash, { type: "boolean" });
// 3. Return map of aliases to hashes
return {
"@schema": metaHash,
"@string": stringHash,
"@number": numberHash,
"@object": objectHash,
"@array": arrayHash,
"@bool": boolHash,
};
}
+43 -20
View File
@@ -197,9 +197,17 @@ describe("createMemoryStore – listByType", () => {
test("bootstrap node is listed under its self type", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const hash = builtinSchemas["@schema"] ?? "";
expect(store.listByType(hash)).toEqual([hash]);
// All built-in schemas should be typed by the meta-schema
const allTypedByMeta = store.listByType(hash);
expect(allTypedByMeta).toContain(hash); // meta-schema itself
expect(allTypedByMeta).toContain(builtinSchemas["@string"] ?? "");
expect(allTypedByMeta).toContain(builtinSchemas["@number"] ?? "");
expect(allTypedByMeta).toContain(builtinSchemas["@object"] ?? "");
expect(allTypedByMeta).toContain(builtinSchemas["@array"] ?? "");
expect(allTypedByMeta).toContain(builtinSchemas["@bool"] ?? "");
});
});
@@ -256,44 +264,59 @@ describe("bootstrap", () => {
);
});
test("returns a valid 13-char hash", async () => {
test("returns a map with 6 built-in schema aliases", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const builtinSchemas = await bootstrap(store);
expect(builtinSchemas).toHaveProperty("@schema");
expect(builtinSchemas).toHaveProperty("@string");
expect(builtinSchemas).toHaveProperty("@number");
expect(builtinSchemas).toHaveProperty("@object");
expect(builtinSchemas).toHaveProperty("@array");
expect(builtinSchemas).toHaveProperty("@bool");
// All values should be valid hashes
for (const hash of Object.values(builtinSchemas)) {
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("node is stored and retrievable", async () => {
test("meta-schema node is stored and retrievable", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
expect(store.has(hash)).toBe(true);
const node = store.get(hash);
expect(store.has(metaHash)).toBe(true);
const node = store.get(metaHash);
expect(node).not.toBeNull();
});
test("node is self-referencing: type === hash", async () => {
test("meta-schema node is self-referencing: type === hash", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
const node = store.get(hash) as CasNode;
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const node = store.get(metaHash) as CasNode;
expect(node.type).toBe(hash);
expect(node.type).toBe(metaHash);
});
test("bootstrap node passes verify()", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
const node = store.get(hash) as CasNode;
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const node = store.get(metaHash) as CasNode;
expect(await verify(hash, node)).toBe(true);
expect(await verify(metaHash, node)).toBe(true);
});
test("bootstrap is idempotent: same hash on repeated calls", async () => {
test("bootstrap is idempotent: same hashes on repeated calls", async () => {
const store = createMemoryStore();
const h1 = await bootstrap(store);
const h2 = await bootstrap(store);
expect(h1).toBe(h2);
expect(store.listByType(h1)).toHaveLength(1);
expect(h1).toEqual(h2);
// All 6 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
});
});
+10 -5
View File
@@ -29,7 +29,8 @@ describe("putSchema", () => {
test("schema node type equals the meta-schema hash", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const schemaHash = await putSchema(store, { type: "string" });
const node = store.get(schemaHash) as CasNode;
@@ -355,7 +356,8 @@ describe("walk", () => {
describe("bootstrap meta-schema self-reference", () => {
test("metaNode.type === metaHash (self-referencing)", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaNode = store.get(metaHash) as CasNode;
expect(metaNode.type).toBe(metaHash);
@@ -363,7 +365,8 @@ describe("bootstrap meta-schema self-reference", () => {
test("schema nodes have type === metaHash", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const schemaHash = await putSchema(store, { type: "string" });
const schemaNode = store.get(schemaHash) as CasNode;
@@ -372,7 +375,8 @@ describe("bootstrap meta-schema self-reference", () => {
test("data nodes have type === schemaHash (not metaHash)", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const schemaHash = await putSchema(store, {
type: "object",
properties: { val: { type: "number" } },
@@ -386,7 +390,8 @@ describe("bootstrap meta-schema self-reference", () => {
test("bootstrap is idempotent across putSchema calls", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
await putSchema(store, { type: "string" });
await putSchema(store, { type: "number" });
+5 -1
View File
@@ -142,7 +142,11 @@ export async function putSchema(
store: Store,
jsonSchema: JSONSchema,
): Promise<Hash> {
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (!metaHash) {
throw new Error("Meta-schema not found in bootstrap result");
}
if (!isValidSchema(jsonSchema)) {
throw new SchemaValidationError(
"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema",
@@ -1593,3 +1593,186 @@ describe("VariableStore - Tag/Label Management", () => {
varStore.close();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// @ Prefix Support for Variable Names
// ──────────────────────────────────────────────────────────────────────────────
describe("VariableStore - @ Prefix Variable Names", () => {
let store: Store;
let dbPath: string;
afterEach(() => {
if (dbPath) {
try {
unlinkSync(dbPath);
} catch {
// ignore
}
}
});
test("should accept variable name with @ prefix in first segment", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaHash = await putSchema(store, { type: "string" });
const hash = await store.put(schemaHash, "test value");
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// Should succeed
const variable = varStore.set("@ucas/test/foo", hash);
expect(variable.name).toBe("@ucas/test/foo");
const retrieved = varStore.get("@ucas/test/foo", schemaHash);
expect(retrieved).not.toBeNull();
expect(retrieved?.name).toBe("@ucas/test/foo");
expect(retrieved?.value).toBe(hash);
varStore.close();
});
test("should accept variable name starting with @", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaHash = await putSchema(store, { type: "string" });
const hash = await store.put(schemaHash, "config value");
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// Single segment with @
varStore.set("@config", hash);
const result = varStore.get("@config", schemaHash);
expect(result).not.toBeNull();
expect(result?.name).toBe("@config");
varStore.close();
});
test("should accept complex @ prefix paths", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaHash = await putSchema(store, { type: "string" });
const hash = await store.put(schemaHash, "test");
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// Multiple valid patterns
const validNames = [
"@ucas/render/template",
"@system/config",
"@foo.bar/baz",
"@app-1/test_2",
];
for (const name of validNames) {
expect(() => varStore.set(name, hash)).not.toThrow();
const retrieved = varStore.get(name, schemaHash);
expect(retrieved).not.toBeNull();
expect(retrieved?.name).toBe(name);
}
varStore.close();
});
test("should reject @ in non-first segment", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaHash = await putSchema(store, { type: "string" });
const hash = await store.put(schemaHash, "test");
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// @ only allowed at start of entire name
const invalidNames = [
"foo/@bar", // @ in second segment
"foo/bar/@baz", // @ in third segment
"foo@bar", // @ within segment (not at start)
];
for (const name of invalidNames) {
expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError);
}
varStore.close();
});
test("should reject @ followed by invalid characters", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaHash = await putSchema(store, { type: "string" });
const hash = await store.put(schemaHash, "test");
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// @ prefix must still follow segment rules after @
const invalidNames = [
"@", // @ alone is empty segment
"@/foo", // empty after @
"@foo bar", // space not allowed
"@foo$bar", // $ not allowed
];
for (const name of invalidNames) {
expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError);
}
varStore.close();
});
test("should still accept all previously valid names", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaHash = await putSchema(store, { type: "string" });
const hash = await store.put(schemaHash, "test");
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// All non-@ names should continue to work
const validNames = [
"simple",
"with.dots",
"with-dashes",
"with_underscores",
"path/to/var",
"foo.bar/baz-qux/test_123",
];
for (const name of validNames) {
expect(() => varStore.set(name, hash)).not.toThrow();
}
varStore.close();
});
test("should still reject previously invalid names", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaHash = await putSchema(store, { type: "string" });
const hash = await store.put(schemaHash, "test");
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
const invalidNames = [
"", // empty
"/leading", // leading slash
"trailing/", // trailing slash
"double//slash", // empty segment
"has space", // space
"has$dollar", // special char
];
for (const name of invalidNames) {
expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError);
}
varStore.close();
});
});
+8 -4
View File
@@ -116,6 +116,7 @@ export class VariableStore {
/**
* Validate variable name format
* @ is allowed at the start of the first segment (system-reserved)
*/
private validateName(name: string): void {
// Rule 1: Cannot be empty
@@ -139,9 +140,10 @@ export class VariableStore {
);
}
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ and no empty segments
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment)
const segments = name.split("/");
for (const segment of segments) {
for (let i = 0; i < segments.length; i++) {
const segment = segments[i] as string;
if (segment === "") {
throw new InvalidVariableNameError(
name,
@@ -150,10 +152,12 @@ export class VariableStore {
}
// Check for invalid characters
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
// First segment can start with @, all segments can contain [a-zA-Z0-9._-]
const regex = i === 0 ? /^@?[a-zA-Z0-9._-]+$/ : /^[a-zA-Z0-9._-]+$/;
if (!regex.test(segment)) {
throw new InvalidVariableNameError(
name,
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
`Segment "${segment}" contains invalid characters (only ${i === 0 ? "@, " : ""}a-z, A-Z, 0-9, ., _, - allowed)`,
);
}
}
@@ -15,7 +15,8 @@ import type { CasNode } from "../src/types.js";
describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
test("1.1: Meta-schema is a valid JSON Schema", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
@@ -25,7 +26,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
test("1.2: Meta-schema self-validates", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
@@ -34,7 +36,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
test("1.3: Meta-schema defines all supported keywords", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
@@ -57,7 +60,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
test("1.4: Meta-schema does not include unsupported keywords", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
@@ -74,7 +78,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
test("1.5: Meta-schema node type equals its own hash", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
@@ -443,7 +448,8 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
test("5.1: Bootstrap hash changes (breaking change)", async () => {
// This is a documentation test - the old hash was different
const store = new MemStore();
const newMetaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const newMetaHash = builtinSchemas["@schema"] ?? "";
// The new hash should be different from the old system metadata hash
// We just verify it's a valid hash format
@@ -585,7 +591,8 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
describe("Test Suite 7: Meta-Schema Content Validation", () => {
test("7.1: Meta-schema allows recursive schema definitions", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();