Compare commits

..

1 Commits

Author SHA1 Message Date
xingyue 3a9b2bf86c fix: add containerName to reporter frontmatter for cleanup routing
Workflow validation failed because reporter's graph edges reference
{{{containerName}}} but reporter's frontmatter didn't include the field.
2026-05-31 19:07:00 +08:00
47 changed files with 2024 additions and 5183 deletions
+5 -2
View File
@@ -348,6 +348,7 @@ roles:
```
4. Collect created issue numbers
5. Include the containerName from your input in your frontmatter output (needed for cleanup)
output: "List created issues. Set $status."
frontmatter:
@@ -358,13 +359,15 @@ roles:
issues:
type: array
items: { type: string }
required: [$status, issues]
containerName: { type: string }
required: [$status, issues, containerName]
- type: object
properties:
$status: { const: "partial" }
created: { type: number }
failed: { type: number }
required: [$status, created, failed]
containerName: { type: string }
required: [$status, created, failed, containerName]
cleanup:
description: "Stops and removes the Docker container"
+19 -51
View File
@@ -32,7 +32,7 @@ Use the in-memory store for tests and embedded apps, the filesystem store for pe
|-------|---------|------|
| Core | `@uncaged/json-cas` | Hashing, schemas, stores, verify, bootstrap |
| Storage | `@uncaged/json-cas-fs` | Filesystem-backed `Store` |
| CLI | `@uncaged/cli-json-cas` | `ucas` command-line tool |
| CLI | `@uncaged/cli-json-cas` | `json-cas` command-line tool |
## Packages
@@ -40,7 +40,7 @@ Use the in-memory store for tests and embedded apps, the filesystem store for pe
|---------|-------------|------|
| [`@uncaged/json-cas`](packages/json-cas/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
| [`@uncaged/json-cas-fs`](packages/json-cas-fs/README.md) | Filesystem-backed CAS store | lib |
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`ucas` binary) | cli |
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`json-cas` binary) | cli |
## Quick Start
@@ -88,62 +88,30 @@ Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/
## CLI Reference
Binary: `ucas` (from `@uncaged/cli-json-cas`; legacy alias `json-cas` is deprecated). Default store:
`~/.uncaged/json-cas`. The store is auto-created and bootstrapped on first use — there is
no `init`/`bootstrap` command, and schemas are ordinary `@schema`-typed nodes (`ucas put
@schema file.json`), so there is no `schema` subcommand.
### Envelope format
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
of the command's `@output/*` result schema and `value` is the command payload. This makes
output self-describing and pipeable: feed any envelope into `render -p` to render its
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
raw (non-envelope) text.
```jsonc
// ucas has <hash>
{ "type": "AYHQD2YA9G667", "value": true }
```
Binary: `json-cas` (from `@uncaged/cli-json-cas`). Default store: `~/.uncaged/json-cas`.
```
Usage: ucas [--store <path>] [--json] <command> [args]
Usage: json-cas [--store <path>] [--json] <command> [args]
Commands (all emit a { type, value } envelope unless noted):
put <type-hash> <file.json> Store node (value = hash) (@output/put)
get <hash> Node payload + metadata (@output/get)
has <hash> Existence boolean (@output/has)
verify <hash> ok / corrupted / invalid (@output/verify)
refs <hash> Direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text (raw output)
render --pipe/-p [options] Render a piped envelope (raw output)
list --type <hash-or-alias> Hashes for a type (value = list) (@output/list)
list-meta Meta-schema hashes (@output/list-meta)
list-schema All schema hashes (@output/list-schema)
var set|get|delete|tag|list ... Variable CRUD (@output/var-*)
template set|get|list|delete ... Output-template CRUD (@output/template-*)
gc Garbage collection (@output/gc)
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
refs <hash> List direct cas_ref edges
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)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--json Compact JSON output
--pipe, -p Read a { type, value } envelope from stdin for render
```
### Pipe examples
```bash
# Store a node, then render the stored content (the put envelope's hash is
# a cas_ref, so render -p dereferences and renders it):
ucas put @schema ./schemas/item.json | ucas render -p
# Render garbage-collection stats:
ucas gc | ucas render -p
# List every schema, then consume the envelope's value array with jq:
ucas list --type @schema | jq -r '.value[]'
```
## Development
-28
View File
@@ -1,33 +1,5 @@
# @uncaged/cli-json-cas
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.6.0
- @uncaged/json-cas-fs@0.6.0
## 0.5.3
### Patch Changes
+31 -80
View File
@@ -4,9 +4,7 @@ CLI tool for json-cas stores.
## Overview
`@uncaged/cli-json-cas` provides the `json-cas` command (also aliased `ucas`) for managing a filesystem-backed store: node CRUD, integrity checks, reference listing, graph walks, variables, and output templates. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
The store is **auto-created and bootstrapped** on first use, so there is no `init`/`bootstrap` command. Schemas are ordinary `@schema`-typed nodes — register one with `ucas put @schema file.json` and list them with `ucas list --type @schema`; there is no dedicated `schema` subcommand.
`@uncaged/cli-json-cas` provides the `json-cas` command for managing a filesystem-backed store: bootstrap, schema registration, node CRUD, integrity checks, reference listing, and graph walks. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
@@ -39,95 +37,48 @@ Usage: json-cas [--store <path>] [--json] <command> [args]
| Flag | Description |
|------|-------------|
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
| `--var-db <path>` | Variable database path (default: `<store>/variables.db`) |
| `--json` | Compact (single-line) JSON output |
### Envelope format
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
of the command's `@output/*` result schema and `value` is the command payload. The output
is therefore self-describing and pipeable: feed any envelope into `render -p` to render its
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
raw, non-envelope text.
```jsonc
// json-cas has <hash>
{ "type": "AYHQD2YA9G667", "value": true }
// json-cas template set <schema-hash> --inline "Hi {{ payload.name }}"
{ "type": "9YJZ09DDAYAWR", "value": { "schemaHash": "7XX5H51CVD9H0", "contentHash": "FC8WACA792B6F" } }
```
| `--json` | Compact JSON output for commands that print JSON |
### Commands
| Command | Envelope `value` | Result schema |
|---------|------------------|---------------|
| `put <type-hash> <file.json>` | stored node hash (string) | `@output/put` |
| `get <hash>` | `{ type, payload, timestamp }` | `@output/get` |
| `has <hash>` | boolean | `@output/has` |
| `verify <hash>` | `ok` / `corrupted` / `invalid` | `@output/verify` |
| `refs <hash>` | hashes (string[]) | `@output/refs` |
| `walk <hash> [--format tree]` | hashes (string[]) or tree string | `@output/walk` |
| `hash <type-hash> <file.json>` | computed hash (string) | `@output/hash` |
| `render <hash> [options]` | raw text (no envelope) | — |
| `render --pipe/-p [options]` | raw text from piped envelope | — |
| `list --type <hash-or-alias>` | hashes (string[]) | `@output/list` |
| `var set <name> <hash> [--tag ...]` | variable object | `@output/var-set` |
| `var get <name> --schema <hash>` | variable object | `@output/var-get` |
| `var delete <name> [--schema <hash>]` | variable or variable[] | `@output/var-delete` |
| `var tag <name> --schema <hash> <ops...>` | variable object | `@output/var-tag` |
| `var list [prefix] [--schema <hash>] [--tag ...]` | variable[] | `@output/var-list` |
| `template set <schema-hash> <file> \| --inline <text>` | `{ schemaHash, contentHash }` | `@output/template-set` |
| `template get <schema-hash>` | template content (string) | `@output/template-get` |
| `template list` | `{ schemaHash, contentHash }[]` | `@output/template-list` |
| `template delete <schema-hash>` | `{ deleted: boolean }` | `@output/template-delete` |
| `gc` | `{ total, reachable, collected, scanned }` | `@output/gc` |
| Command | Description |
|---------|-------------|
| `init` | Create store directory and write bootstrap seed; prints meta hash |
| `bootstrap` | Write meta-schema seed into existing store; prints hash |
| `schema put <file.json>` | Register schema from file; prints type hash |
| `schema get <type-hash>` | Print schema JSON |
| `schema list` | List all schemas (`hash name`) |
| `schema validate <hash>` | Validate node against its schema; prints `valid` / `invalid` |
| `put <type-hash> <file.json>` | Store node; prints content hash |
| `get <hash>` | Print full node as JSON |
| `has <hash>` | Print `true` or `false` |
| `verify <hash>` | Verify integrity; prints `ok` or `corrupted` |
| `refs <hash>` | Print direct `cas_ref` targets (one per line) |
| `walk <hash>` | BFS traversal; one hash per line |
| `walk <hash> --format tree` | Tree-formatted traversal |
| `hash <type-hash> <file.json>` | Compute hash without storing |
| `cat <hash>` | Print node JSON |
| `cat <hash> --payload` | Print payload only |
### Examples
```bash
# Register a schema (schemas are plain @schema nodes) and store a payload
json-cas put @schema ./schemas/item.json
# → { "type": "...", "value": "0123456789ABC" } (the schema's type hash)
# Initialize default store at ~/.uncaged/json-cas
json-cas init
json-cas put 0123456789ABC ./payloads/item.json
# → { "type": "...", "value": "<content-hash>" }
# Use a custom store path
json-cas --store ./data/cas bootstrap
# Register a schema and store a payload
json-cas schema put ./schemas/item.json
# → prints type hash, e.g. 0123456789ABCD
json-cas put 0123456789ABCD ./payloads/item.json
# → prints content hash
json-cas get <content-hash> --json
json-cas verify <content-hash>
json-cas walk <content-hash> --format tree
# List every registered schema, then extract the hashes with jq
json-cas list --type @schema | jq -r '.value[]'
```
### Pipe composition
Because every command shares the `{ type, value }` envelope, output composes directly into
`render -p`:
```bash
# put emits a cas_ref hash envelope; render -p dereferences and renders the node
json-cas put @schema ./schemas/item.json | json-cas render -p
# render gc statistics
json-cas gc | json-cas render -p
# render every schema referenced by a list result
json-cas list --type @schema | json-cas render -p
```
### Templates
`template` commands manage the LiquidJS template bound to a schema (stored as a
`@ucas/template/text/<schema-hash>` variable). `render <hash>` uses the template registered
for the node's type, falling back to YAML when none exists.
```bash
# Bind a template to a schema, then render a node of that type
json-cas template set 0123456789ABC --inline "Item: {{ payload.name }}"
json-cas render <content-hash>
# → Item: Widget
```
## Internal Structure
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-json-cas",
"version": "0.6.0",
"version": "0.5.3",
"type": "module",
"bin": {
"json-cas": "./src/index.ts",
@@ -8,10 +8,10 @@
},
"scripts": {
"test": "bun test",
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"@uncaged/json-cas": "^0.6.0",
"@uncaged/json-cas-fs": "^0.6.0"
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3"
}
}
File diff suppressed because it is too large Load Diff
+295 -193
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bun
import { existsSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { Hash, Store, VariableStore } from "@uncaged/json-cas";
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
import {
bootstrap,
CasNodeNotFoundError,
@@ -17,14 +17,14 @@ import {
refs,
renderAsync,
renderDirect,
SchemaValidationError,
TagLabelConflictError,
VariableNotFoundError,
validate,
verify,
walk,
wrapEnvelope,
} from "@uncaged/json-cas";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Argument parsing ----
@@ -41,7 +41,6 @@ const VALUE_FLAGS = new Set([
"decay",
"epsilon",
"inline",
"type",
]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
@@ -75,8 +74,6 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
} else {
flags[key] = true;
}
} else if (arg === "-p") {
flags.p = true;
} else {
positional.push(arg);
}
@@ -115,33 +112,27 @@ function readJsonFile(file: string): unknown {
}
}
async function readStdinJson(): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const input = Buffer.concat(chunks).toString("utf-8").trim();
if (!input) {
die("No input on stdin. Pipe JSON content.");
}
try {
return JSON.parse(input);
} catch {
return die("Invalid JSON on stdin.");
}
}
/**
* Open the filesystem-backed CAS store.
* Automatically creates directory and bootstraps if needed.
* Validate that the store directory exists.
* Dies with a clear error message if not found.
*/
async function openStore(): Promise<Store> {
const fullPath = resolve(storePath);
return await openFsStore(fullPath);
function validateStoreExists(path: string): void {
if (!existsSync(path)) {
die(`Store not found at ${path}`);
}
}
async function openVarStore(): Promise<VariableStore> {
const store = await openStore();
function openStore(shouldCreate = false): Store {
const fullPath = resolve(storePath);
if (!shouldCreate) {
validateStoreExists(fullPath);
}
return createFsStore(fullPath);
}
function openVarStore(): VariableStore {
const store = openStore(true);
mkdirSync(resolve(storePath), { recursive: true });
return createVariableStore(resolve(varDbPath), store);
}
@@ -150,9 +141,12 @@ async function openVarStore(): Promise<VariableStore> {
* If the input starts with @, resolve it via bootstrap
* Otherwise, return the hash as-is
*/
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
async function resolveTypeHash(
typeHashOrAlias: string,
shouldCreate = false,
): Promise<Hash> {
if (typeHashOrAlias.startsWith("@")) {
const store = await openStore();
const store = openStore(shouldCreate);
const builtinSchemas = await bootstrap(store);
const resolvedHash = builtinSchemas[typeHashOrAlias];
if (!resolvedHash) {
@@ -163,6 +157,55 @@ async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
return typeHashOrAlias;
}
/**
* Get the Variable schema's CAS hash
* This is the type hash used in JSON envelopes
*/
async function getVariableSchemaHash(): Promise<Hash> {
const store = openStore();
// Define the Variable JSON Schema (updated for new model with composite key)
const variableSchema: JSONSchema = {
title: "Variable",
type: "object",
properties: {
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: [
"name",
"schema",
"value",
"created",
"updated",
"tags",
"labels",
],
};
// Compute hash or retrieve from store
const hash = await putSchema(store, variableSchema);
return hash;
}
/**
* Wrap Variable output in JSON envelope
*/
async function wrapVariableEnvelope(
variable: unknown,
): Promise<{ type: Hash; value: unknown }> {
const typeHash = await getVariableSchemaHash();
return {
type: typeHash,
value: variable,
};
}
/**
* Parse tag/label arguments
* Returns: { tags: Record<string, string>, labels: string[], deleteNames: string[] }
@@ -197,36 +240,85 @@ function parseTagsLabels(args: string[]): {
// ---- Commands ----
async function cmdPut(args: string[]): Promise<void> {
const isPipe = flags.pipe === true || flags.p === true;
const typeHashOrAlias = args[0];
const file = isPipe ? undefined : args[1];
if (!typeHashOrAlias || (!isPipe && !file))
die(
"Usage: json-cas put <type-hash> <file.json>\n json-cas put <type-hash> --pipe/-p",
);
if (isPipe && args[1])
die("Cannot use --pipe/-p with a file argument. Use one or the other.");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = isPipe ? await readStdinJson() : readJsonFile(file as string);
const store = await openStore();
// Schema nodes: use putSchema() which validates via isValidSchema() (recursive)
// instead of ajv against meta-schema (which can't express recursive constraints)
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"];
if (typeHash === metaHash) {
try {
const hash = await putSchema(store, payload as Record<string, unknown>);
out(await wrapEnvelope(store, "@output/put", hash));
} catch (_e) {
console.error(
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
);
process.exit(1);
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);
}
return;
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 payload = readJsonFile(file);
const store = openStore(true);
// Check if schema exists
const schema = getSchema(store, typeHash);
@@ -245,55 +337,51 @@ async function cmdPut(args: string[]): Promise<void> {
}
const hash = await store.put(typeHash, payload);
out(await wrapEnvelope(store, "@output/put", hash));
console.log(hash);
}
async function cmdGet(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas get <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
out(await wrapEnvelope(store, "@output/get", node));
out(node);
}
async function cmdHas(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas has <hash>");
const store = await openStore();
out(await wrapEnvelope(store, "@output/has", store.has(hash)));
const store = openStore();
console.log(String(store.has(hash)));
}
async function cmdVerify(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas verify <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const ok = await verify(hash, node);
let status: string;
if (!ok) {
status = "corrupted";
} else {
status = validate(store, node) ? "ok" : "invalid";
}
out(await wrapEnvelope(store, "@output/verify", status));
console.log(ok ? "ok" : "corrupted");
}
async function cmdRefs(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas refs <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const refHashes = refs(store, node);
out(await wrapEnvelope(store, "@output/refs", refHashes));
for (const r of refHashes) {
console.log(r);
}
}
async function cmdWalk(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas walk <hash> [--format tree]");
const store = await openStore();
const store = openStore();
const format = flags.format;
if (format === "tree") {
@@ -303,16 +391,15 @@ async function cmdWalk(args: string[]): Promise<void> {
});
const printed = new Set<Hash>();
const lines: string[] = [];
function printNode(h: Hash, prefix: string, isLast: boolean): void {
const connector = prefix === "" ? "" : isLast ? "└── " : "├── ";
if (printed.has(h)) {
lines.push(`${prefix}${connector}${h} (seen)`);
console.log(`${prefix}${connector}${h} (seen)`);
return;
}
printed.add(h);
lines.push(`${prefix}${connector}${h}`);
console.log(`${prefix}${connector}${h}`);
const kids = childMap.get(h) ?? [];
const childPrefix =
@@ -323,31 +410,22 @@ async function cmdWalk(args: string[]): Promise<void> {
}
printNode(hash, "", true);
out(await wrapEnvelope(store, "@output/walk", lines.join("\n")));
} else {
const hashes: Hash[] = [];
walk(store, hash, (h) => {
hashes.push(h);
console.log(h);
});
out(await wrapEnvelope(store, "@output/walk", hashes));
}
}
async function cmdHash(args: string[]): Promise<void> {
const isPipe = flags.pipe === true || flags.p === true;
const typeHashOrAlias = args[0];
const file = isPipe ? undefined : args[1];
if (!typeHashOrAlias || (!isPipe && !file))
die(
"Usage: json-cas hash <type-hash> <file.json>\n json-cas hash <type-hash> --pipe/-p",
);
if (isPipe && args[1])
die("Cannot use --pipe/-p with a file argument. Use one or the other.");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = isPipe ? await readStdinJson() : readJsonFile(file as string);
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas hash <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
const payload = readJsonFile(file);
const hash = await computeHash(typeHash, payload);
const store = await openStore();
out(await wrapEnvelope(store, "@output/hash", hash));
console.log(hash);
}
async function cmdRender(args: string[]): Promise<void> {
@@ -364,7 +442,7 @@ async function cmdRender(args: string[]): Promise<void> {
);
}
const store = await openStore();
const store = openStore();
// Parse numeric options
const resolution =
@@ -439,7 +517,7 @@ async function cmdRender(args: string[]): Promise<void> {
);
process.stdout.write(output);
} else {
const varStore = await openVarStore();
const varStore = openVarStore();
const output = await renderAsync(store, hash, {
resolution,
decay,
@@ -460,6 +538,19 @@ 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];
@@ -469,8 +560,7 @@ async function cmdVarSet(args: string[]): Promise<void> {
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
}
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
@@ -497,7 +587,8 @@ async function cmdVarSet(args: string[]): Promise<void> {
: undefined;
const variable = varStore.set(name, value, options);
out(await wrapEnvelope(store, "@output/var-set", variable));
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof InvalidVariableNameError ||
@@ -520,15 +611,15 @@ async function cmdVarGet(args: string[]): Promise<void> {
die("Usage: json-cas var get <name> --schema <hash>");
}
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
const varStore = openVarStore();
try {
const variable = varStore.get(name, schema);
if (variable === null) {
die(`Error: Variable not found: name=${name}, schema=${schema}`);
}
out(await wrapEnvelope(store, "@output/var-get", variable));
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} finally {
varStore.close();
}
@@ -542,18 +633,19 @@ async function cmdVarDelete(args: string[]): Promise<void> {
die("Usage: json-cas var delete <name> [--schema <hash>]");
}
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
const varStore = openVarStore();
try {
if (schema !== undefined) {
// Precise deletion: remove specific (name, schema) variant
const variable = varStore.remove(name, schema);
out(await wrapEnvelope(store, "@output/var-delete", variable));
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} else {
// Batch deletion: remove all variants for this name
const variables = varStore.remove(name);
out(await wrapEnvelope(store, "@output/var-delete", variables));
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
}
} catch (e) {
if (e instanceof VariableNotFoundError) {
@@ -578,8 +670,7 @@ async function cmdVarTag(args: string[]): Promise<void> {
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
const varStore = openVarStore();
try {
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
@@ -590,7 +681,8 @@ async function cmdVarTag(args: string[]): Promise<void> {
delete: deleteNames.length > 0 ? deleteNames : undefined,
});
out(await wrapEnvelope(store, "@output/var-tag", variable));
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
@@ -610,8 +702,7 @@ async function cmdVarList(args: string[]): Promise<void> {
const schema = flags.schema as string | undefined;
const tagFlags = flags.tag;
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
@@ -633,7 +724,8 @@ async function cmdVarList(args: string[]): Promise<void> {
tags: Object.keys(tags).length > 0 ? tags : undefined,
labels: labels.length > 0 ? labels : undefined,
});
out(await wrapEnvelope(store, "@output/var-list", variables));
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
} catch (e) {
if (e instanceof InvalidVariableNameError) {
die(`Error: ${e.message}`);
@@ -652,7 +744,8 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
}
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -702,12 +795,10 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
const varName = `@ucas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
out(
await wrapEnvelope(store, "@output/template-set", {
schemaHash,
contentHash,
}),
);
out({
schemaHash,
contentHash,
});
} catch (e) {
if (e instanceof CasNodeNotFoundError) {
die(`Error: ${e.message}`);
@@ -725,7 +816,8 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
die("Usage: json-cas template get <schema-hash>");
}
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -743,16 +835,16 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
die(`Error: Content not found in CAS: ${variable.value}`);
}
out(
await wrapEnvelope(store, "@output/template-get", node.payload as string),
);
// Output raw text (not JSON)
process.stdout.write(node.payload as string);
} finally {
varStore.close();
}
}
async function cmdTemplateList(_args: string[]): Promise<void> {
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -762,12 +854,24 @@ async function cmdTemplateList(_args: string[]): Promise<void> {
schema: stringHash,
});
const templates = variables.map((v) => ({
schemaHash: v.name.replace("@ucas/template/text/", ""),
contentHash: v.value,
}));
const templates = variables.map((v) => {
const schemaHash = v.name.replace("@ucas/template/text/", "");
out(await wrapEnvelope(store, "@output/template-list", templates));
// Get content for preview
const node = store.get(v.value);
const content = (node?.payload as string | undefined) ?? "";
// Truncate preview to 80 chars
const preview =
content.length > 80 ? `${content.slice(0, 77)}...` : content;
return {
schemaHash,
preview,
};
});
out(templates);
} finally {
varStore.close();
}
@@ -780,7 +884,8 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
die("Usage: json-cas template delete <schema-hash>");
}
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -788,9 +893,7 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
const stringHash = await resolveTypeHash("@string");
varStore.remove(varName, stringHash);
out(
await wrapEnvelope(store, "@output/template-delete", { deleted: true }),
);
out({ deleted: true });
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: Template not found for schema: ${schemaHash}`);
@@ -802,70 +905,48 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
}
async function cmdGc(_args: string[]): Promise<void> {
const store = await openStore();
const store = createFsStore(storePath);
const varStore = createVariableStore(varDbPath, store);
try {
const stats = gc(store, varStore);
out(await wrapEnvelope(store, "@output/gc", stats));
out(stats);
} finally {
varStore.close();
}
}
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();
const hashes = Array.from(store.listByType(typeHash));
out(await wrapEnvelope(store, "@output/list", hashes));
}
async function cmdListMeta(_args: string[]): Promise<void> {
const store = await openStore();
const hashes = store.listMeta();
out(await wrapEnvelope(store, "@output/list-meta", hashes));
}
async function cmdListSchema(_args: string[]): Promise<void> {
const store = await openStore();
const hashes = store.listSchemas();
out(await wrapEnvelope(store, "@output/list-schema", hashes));
}
function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
All JSON commands emit a { type, value } envelope. The type is the hash of the
command's @output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (cas_ref hashes are expanded).
Commands:
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
list-meta List meta-schema hashes (value=string[]) (@output/list-meta)
list-schema List all schema hashes (value=string[]) (@output/list-schema)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@output/var-tag)
template set <schema-hash> <file> | --inline <text> Set template for schema (@output/template-set)
template get <schema-hash> Get template content (value=string) (@output/template-get)
template list List all templates (@output/template-list)
template delete <schema-hash> Delete template for schema (@output/template-delete)
gc Run garbage collection (@output/gc)
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
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
render <hash> [options] Render node as YAML with resolution decay
render --pipe/-p [options] Render { type, value } from stdin
cat <hash> [--payload] Output node (--payload for payload only)
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
var delete <name> [--schema <hash>] Delete variable(s)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables
var tag <name> --schema <hash> <operations...> Modify tags/labels
template set <schema-hash> <file> | --inline <text> Set template for schema
template get <schema-hash> Get template content as raw text
template list List all templates
template delete <schema-hash> Delete template for schema
gc Run garbage collection
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
@@ -877,7 +958,7 @@ Flags:
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)`);
--pipe, -p Read { type, value } JSON from stdin for render`);
}
// ---- Dispatch ----
@@ -890,6 +971,35 @@ 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;
@@ -922,16 +1032,8 @@ switch (cmd) {
await cmdRender(rest);
break;
case "list":
await cmdList(rest);
break;
case "list-meta":
await cmdListMeta(rest);
break;
case "list-schema":
await cmdListSchema(rest);
case "cat":
await cmdCat(rest);
break;
case "var": {
@@ -21,7 +21,7 @@ beforeEach(() => {
);
storePath = join(testDir, "store");
varDbPath = join(testDir, "variables.db");
cliPath = join(import.meta.dir, "../src/index.ts");
cliPath = join(import.meta.dir, "index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
@@ -103,10 +103,9 @@ describe("template set", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("contentHash");
expect(envelope.value.schemaHash).toBe(stringHash);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
});
test("set template with --inline flag", async () => {
@@ -123,10 +122,9 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("contentHash");
expect(envelope.value.schemaHash).toBe(stringHash);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
});
test("update existing template (idempotent)", async () => {
@@ -150,12 +148,12 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value).toHaveProperty("contentHash");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
// Verify we can get the new version
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(JSON.parse(getOut).value).toBe("Version 2");
expect(getOut).toBe("Version 2");
});
test("error when file not found", async () => {
@@ -225,7 +223,7 @@ describe("template set", () => {
// Verify content
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(JSON.parse(getOut).value).toBe(multilineContent);
expect(getOut).toBe(multilineContent);
});
test("support empty templates", async () => {
@@ -242,8 +240,8 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value).toHaveProperty("contentHash");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
});
test("error when neither file nor --inline provided", async () => {
@@ -273,12 +271,12 @@ describe("template set", () => {
// Verify content preserved
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(JSON.parse(getOut).value).toBe(specialContent);
expect(getOut).toBe(specialContent);
});
});
describe("template get", () => {
test("retrieve template as envelope value", async () => {
test("retrieve template as raw text", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
@@ -293,9 +291,7 @@ describe("template get", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toBe(content);
expect(stdout).toBe(content);
});
test("error when template not found", async () => {
@@ -313,26 +309,27 @@ describe("template get", () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// The envelope's value preserves exact whitespace (JSON-escaped),
// so trimming the surrounding JSON output is harmless.
// Note: runCli helper trims stdout, so we test with content that doesn't have leading/trailing whitespace
// The actual CLI preserves whitespace correctly
const content = "spaces\n\ttabs\t\nmixed";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout } = await runCli("template", "get", stringHash);
expect(JSON.parse(stdout).value).toBe(content);
expect(stdout).toBe(content);
});
test("support multi-line templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Note: runCli helper trims stdout, so trailing newline will be removed
const multiline = "Line 1\nLine 2\nLine 3";
await runCli("template", "set", stringHash, "--inline", multiline);
const { stdout } = await runCli("template", "get", stringHash);
expect(JSON.parse(stdout).value).toBe(multiline);
expect(stdout).toBe(multiline);
});
});
@@ -349,40 +346,34 @@ describe("template list", () => {
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(Array.isArray(envelope.value)).toBe(true);
expect(envelope.value.length).toBeGreaterThanOrEqual(1);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBeGreaterThanOrEqual(1);
// Check structure
const item = envelope.value[0];
const item = output[0];
expect(item).toHaveProperty("schemaHash");
expect(item).toHaveProperty("contentHash");
expect(item).toHaveProperty("preview");
});
test("entry contentHash matches set result", async () => {
test("preview truncation for long content", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stdout: setOut } = await runCli(
"template",
"set",
stringHash,
"--inline",
"Some template content",
);
const { contentHash } = JSON.parse(setOut).value;
const longContent = "a".repeat(200);
await runCli("template", "set", stringHash, "--inline", longContent);
const { stdout } = await runCli("template", "list");
const value = JSON.parse(stdout).value as Array<{
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
contentHash: string;
preview: string;
}>;
const item = value.find((i) => i.schemaHash === stringHash);
const item = output.find((i) => i.schemaHash === stringHash);
expect(item).toBeDefined();
if (item) {
expect(item.contentHash).toBe(contentHash);
expect(item.preview.length).toBeLessThan(longContent.length);
expect(item.preview).toContain("...");
}
});
@@ -391,9 +382,9 @@ describe("template list", () => {
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(Array.isArray(envelope.value)).toBe(true);
expect(envelope.value.length).toBe(0);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBe(0);
});
test("exclude non-template variables", async () => {
@@ -409,14 +400,14 @@ describe("template list", () => {
const { stdout } = await runCli("template", "list");
const envelope = JSON.parse(stdout);
const output = JSON.parse(stdout);
// Should only contain template variables
for (const item of envelope.value) {
for (const item of output) {
expect(item.schemaHash).toBeDefined();
}
});
test("output JSON envelope with array value", async () => {
test("output JSON array format", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
@@ -427,8 +418,27 @@ describe("template list", () => {
// Should be valid JSON
expect(() => JSON.parse(stdout)).not.toThrow();
const envelope = JSON.parse(stdout);
expect(Array.isArray(envelope.value)).toBe(true);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
});
test("preview shows beginning of content", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const content = "Start of template...";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
preview: string;
}>;
const item = output.find((i) => i.schemaHash === stringHash);
if (item) {
expect(item.preview).toContain("Start");
}
});
});
@@ -448,10 +458,9 @@ describe("template delete", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("deleted");
expect(envelope.value.deleted).toBe(true);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("deleted");
expect(output.deleted).toBe(true);
// Verify template is gone
const { exitCode: getExitCode } = await runCli(
@@ -486,13 +495,13 @@ describe("template delete", () => {
// Verify second still exists
const { stdout } = await runCli("template", "list");
const value = JSON.parse(stdout).value as Array<{
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
contentHash: string;
preview: string;
}>;
// Should not find deleted template
const deleted = value.find((i) => i.schemaHash === stringHash);
const deleted = output.find((i) => i.schemaHash === stringHash);
expect(deleted).toBeUndefined();
});
@@ -510,7 +519,7 @@ describe("template delete", () => {
"--inline",
"Content",
);
const { contentHash } = JSON.parse(setOut).value;
const { contentHash } = JSON.parse(setOut);
// Delete the template variable
await runCli("template", "delete", stringHash);
@@ -568,7 +577,7 @@ describe("template integration", () => {
stringHash,
);
expect(getExit).toBe(0);
expect(JSON.parse(getOut).value).toBe(content);
expect(getOut).toBe(content);
// List
const { stdout: listOut, exitCode: listExit } = await runCli(
@@ -576,7 +585,7 @@ describe("template integration", () => {
"list",
);
expect(listExit).toBe(0);
const listData = JSON.parse(listOut).value;
const listData = JSON.parse(listOut);
expect(listData.length).toBeGreaterThan(0);
// Delete
@@ -617,8 +626,8 @@ describe("template integration", () => {
// List should show all
const { stdout } = await runCli("template", "list");
const value = JSON.parse(stdout).value;
expect(value.length).toBeGreaterThanOrEqual(1);
const output = JSON.parse(stdout);
expect(output.length).toBeGreaterThanOrEqual(1);
});
});
@@ -21,7 +21,7 @@ beforeEach(() => {
);
storePath = join(testDir, "store");
varDbPath = join(testDir, "variables.db");
cliPath = join(import.meta.dir, "../src/index.ts");
cliPath = join(import.meta.dir, "index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
@@ -1,249 +0,0 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: json-cas var set <name> <hash> [--tag <tag>...]"`;
exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`;
exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = `
"Usage: json-cas [--store <path>] [--json] <command> [args]
All JSON commands emit a { type, value } envelope. The type is the hash of the
command's @output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (cas_ref hashes are expanded).
Commands:
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
list-meta List meta-schema hashes (value=string[]) (@output/list-meta)
list-schema List all schema hashes (value=string[]) (@output/list-schema)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@output/var-tag)
template set <schema-hash> <file> | --inline <text> Set template for schema (@output/template-set)
template get <schema-hash> Get template content (value=string) (@output/template-get)
template list List all templates (@output/template-list)
template delete <schema-hash> Delete template for schema (@output/template-delete)
gc Run garbage collection (@output/gc)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)"
`;
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
{
"type": "5KVHSESSX7N96",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {},
"value": "6KZ930XYK2MHB",
},
}
`;
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "7D4R2MJ1EYF08",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {},
"value": "6KZ930XYK2MHB",
},
}
`;
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
{
"type": "E8158M25YNR9M",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {},
"value": "6KZ930XYK2MHB",
},
],
}
`;
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
{
"type": "E8158M25YNR9M",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {},
"value": "6KZ930XYK2MHB",
},
],
}
`;
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
{
"type": "5KVHSESSX7N96",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {},
"value": "AE80669JYG4K2",
},
}
`;
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
{
"type": "2WX4XRYSACRWR",
"value": {
"labels": [
"important",
],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {
"env": "prod",
},
"value": "6KZ930XYK2MHB",
},
}
`;
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "E8158M25YNR9M",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {
"env": "prod",
},
"value": "6KZ930XYK2MHB",
},
],
}
`;
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
{
"type": "E8158M25YNR9M",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {
"env": "prod",
},
"value": "6KZ930XYK2MHB",
},
],
}
`;
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
{
"type": "2WX4XRYSACRWR",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {
"env": "prod",
},
"value": "6KZ930XYK2MHB",
},
}
`;
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
{
"type": "F89626QEK09YY",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {
"env": "prod",
},
"value": "6KZ930XYK2MHB",
},
],
}
`;
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=8WAZV39SD724T"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
{
"type": "A8RGY9B1JSCDJ",
"value": {
"contentHash": "0359SHMP7VBFD",
"schemaHash": "8WAZV39SD724T",
},
}
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
{
"type": "64FP3TWKK69YH",
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
}
`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
{
"type": "BE20H482GBNM3",
"value": [
{
"contentHash": "0359SHMP7VBFD",
"schemaHash": "8WAZV39SD724T",
},
],
}
`;
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
{
"type": "0FXB2GS8Y9R1R",
"value": {
"deleted": true,
},
}
`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 8WAZV39SD724T"`;
@@ -1,14 +0,0 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"type": "CE40D09H75NFZ",
"value": {
"payload": {
"age": 30,
"name": "Alice",
},
"type": "8WAZV39SD724T",
},
}
`;
@@ -1,5 +0,0 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
@@ -1,3 +0,0 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
@@ -1,24 +0,0 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
{
"type": "BZ31JDDWX2AWH",
"value": "ok",
}
`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
"{
"type": "DG8SAB75PV9P7",
"value": []
}"
`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
"{
"type": "EVHG7Q7FK83H0",
"value": [
"6KZ930XYK2MHB"
]
}"
`;
-187
View File
@@ -1,187 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// ---- @ Alias Resolution Tests ----
let testDir: string;
let storePath: string;
let cliPath: string;
beforeEach(() => {
// Create unique temp directory for each test
testDir = join(
tmpdir(),
`json-cas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
// Clean up test directory
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
/**
* Run CLI command and return stdout, stderr, and exit code
*/
async function runCliAlias(...args: string[]): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--store", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
}
/** Extract the `value` field from a { type, value } envelope JSON string. */
function envValue(json: string): unknown {
return (JSON.parse(json.trim()) as { value: unknown }).value;
}
describe("@ Alias Resolution - put", () => {
test("ucas put @string <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify("hello world"));
const { stdout, stderr, exitCode } = await runCliAlias(
"put",
"@string",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
// Should output an envelope whose value is a valid hash (13 chars)
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @number <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "42");
const { stdout, exitCode } = await runCliAlias(
"put",
"@number",
payloadFile,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @object <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ foo: "bar" }));
const { stdout, exitCode } = await runCliAlias(
"put",
"@object",
payloadFile,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @invalid <file> should fail", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "{}");
const { stderr, exitCode } = await runCliAlias(
"put",
"@invalid",
payloadFile,
);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
});
test("ucas put @schema with nested type constraints should succeed", async () => {
await runCliAlias("init");
const schemaFile = join(testDir, "constrained-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 50 },
age: { type: "number", minimum: 0, maximum: 150 },
tags: {
type: "array",
items: { type: "string" },
minItems: 1,
uniqueItems: true,
},
},
required: ["name"],
}),
);
const { stdout, stderr, exitCode } = await runCliAlias(
"put",
"@schema",
schemaFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
describe("@ Alias Resolution - hash", () => {
test("ucas hash @string <file> should compute hash without storing", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify("test"));
const { stdout, stderr, exitCode } = await runCliAlias(
"hash",
"@string",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
@@ -1,436 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
const pkgPath = resolve(import.meta.dir, "../package.json");
// --- ucas command alias tests (from cli.test.ts) ---
describe("ucas command alias", () => {
test("T1: ucas bin entry exists in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe("./src/index.ts");
});
test("T2: json-cas bin entry is preserved in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin["json-cas"]).toBe("./src/index.ts");
});
test("T3: ucas command is executable and shows help", async () => {
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
test("T4: both commands point to the same entrypoint", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
});
});
// --- e2e Phase 7: Edge Cases ---
describe("Phase 7: Edge Cases", () => {
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
test("7.1 get non-existent hash errors gracefully", async () => {
const { stderr, exitCode } = await runCli(["get", "AAAAAAAAAAAAA"]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
test("7.2 put with non-existent file errors with ENOENT", async () => {
const { stderr, exitCode } = await runCli([
"put",
typeHash,
"/nonexistent/file.json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("ENOENT");
});
test("7.3 var set empty name errors", async () => {
const { stderr, exitCode } = await runCli(["var", "set", "", nodeHash]);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
expect(stderr).toMatchSnapshot();
});
test("7.4 var set name with invalid chars errors", async () => {
const { stderr, exitCode } = await runCli([
"var",
"set",
"invalid name!",
nodeHash,
]);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
expect(stderr).toMatchSnapshot();
});
test("7.5 no subcommand shows help text", async () => {
const { stdout, stderr, exitCode: _exitCode } = await runCli([]);
const combined = stdout + stderr;
expect(combined.length).toBeGreaterThan(0);
expect(combined).toMatchSnapshot();
expect(combined.toLowerCase()).toContain("usage");
});
test("7.6 --store path is a file errors", async () => {
const fileAsStore = join(tmpStore, "not-a-directory");
writeFileSync(fileAsStore, "test");
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
fileAsStore,
"--var-db",
varDbPath,
"get",
"AAAAAAAAAAAAA",
],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stderr = (await new Response(proc.stderr).text()).trim();
expect(exitCode).not.toBe(0);
expect(stderr).toContain("not a directory");
});
});
// --- e2e Phase 3: Variable System (edge cases from e2e.test.ts) ---
describe("Phase 3: Variable System", () => {
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
test("3.1 var set creates variable", async () => {
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
nodeHash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.2 var get returns variable", async () => {
const { stdout, exitCode } = await runCli([
"var",
"get",
"myapp/config",
"--schema",
typeHash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain(nodeHash);
});
test("3.3 var list shows all variables", async () => {
const { stdout, exitCode } = await runCli(["var", "list"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain("myapp/config");
});
test("3.4 var list prefix filters by prefix", async () => {
const { stdout, exitCode } = await runCli(["var", "list", "myapp/"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain("myapp/config");
});
test("3.5 var set upsert updates existing variable", async () => {
const node2File = join(tmpStore, "node2.json");
writeFileSync(node2File, JSON.stringify({ name: "Bob", age: 25 }));
const { stdout: node2Out } = await runCli(["put", typeHash, node2File]);
const node2Hash = envValue(node2Out) as string;
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
node2Hash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
// Restore original value
await runCli(["var", "set", "myapp/config", nodeHash]);
});
test("3.6 var tag adds kv tag and label", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"myapp/config",
"--schema",
typeHash,
"env:prod",
"important",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.7 var list --tag env:prod filters by kv tag", async () => {
const { stdout, exitCode } = await runCli([
"var",
"list",
"--tag",
"env:prod",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("myapp/config");
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.8 var list --tag important filters by label", async () => {
const { stdout, exitCode } = await runCli([
"var",
"list",
"--tag",
"important",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("myapp/config");
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.9 var tag remove deletes label", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"myapp/config",
"--schema",
typeHash,
":important",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
// Verify label is gone
const { stdout: listOut } = await runCli([
"var",
"list",
"--tag",
"important",
]);
expect(listOut).not.toContain("myapp/config");
});
test("3.10 var delete removes variable", async () => {
const { exitCode, stdout } = await runCli([
"var",
"delete",
"myapp/config",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.11 var get deleted variable returns not found", async () => {
const { stderr, exitCode } = await runCli([
"var",
"get",
"myapp/config",
"--schema",
typeHash,
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// --- e2e Phase 4: Template System ---
describe("Phase 4: Template System", () => {
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
test("4.1 template set registers template", async () => {
const tmplFile = join(tmpStore, "test.liquid");
writeFileSync(tmplFile, "Name: {{ payload.name }}, Age: {{ payload.age }}");
const { exitCode, stdout } = await runCli([
"template",
"set",
typeHash,
tmplFile,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.2 template get returns template text", async () => {
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe(
"Name: {{ payload.name }}, Age: {{ payload.age }}",
);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.3 template list shows registered templates", async () => {
const { stdout, exitCode } = await runCli(["template", "list"]);
expect(exitCode).toBe(0);
expect(stdout).toContain(typeHash);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.4 template delete removes template", async () => {
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.5 template get deleted template returns not found", async () => {
const { stderr, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
-125
View File
@@ -1,125 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { envValue } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
// Set a var referencing the node so it survives GC
await runCli(["var", "set", "gc-test/ref", nodeHash]);
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
// ---- Phase 6: GC ----
describe("Phase 6: GC", () => {
test("6.1 gc runs without error", async () => {
const { exitCode, stdout } = await runCli(["gc"]);
expect(exitCode).toBe(0);
// Assert structural shape only — exact counts depend on phase history
const result = envValue(stdout) as Record<string, unknown>;
expect(typeof result.total).toBe("number");
expect(typeof result.reachable).toBe("number");
expect(typeof result.collected).toBe("number");
expect(typeof result.scanned).toBe("number");
expect(result.total as number).toBeGreaterThanOrEqual(
result.reachable as number,
);
});
test("6.2 gc | render -p renders the gc stats", async () => {
const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]);
expect(gcExit).toBe(0);
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"render",
"--pipe",
],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(gcOut);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
expect(exitCode).toBe(0);
// gc value is an object { total, reachable, collected, scanned }
expect(stdout).toContain("total:");
});
test("6.3 gc preserves node referenced by a var", async () => {
const { exitCode } = await runCli(["gc"]);
expect(exitCode).toBe(0);
const { stdout } = await runCli(["has", nodeHash]);
expect(envValue(stdout)).toBe(true);
});
test("6.4 gc reclaims orphan node", async () => {
const orphanFile = join(tmpStore, "orphan.json");
writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 }));
const { stdout: orphanOut } = await runCli(["put", typeHash, orphanFile]);
const orphanHash = envValue(orphanOut) as string;
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
expect(envValue(beforeGc)).toBe(true);
await runCli(["gc"]);
const { stdout: afterGc } = await runCli(["has", orphanHash]);
expect(envValue(afterGc)).toBe(false);
});
});
-107
View File
@@ -1,107 +0,0 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
export {
join,
mkdirSync,
mkdtempSync,
readFileSync,
resolve,
rmSync,
tmpdir,
writeFileSync,
};
export const entrypoint = resolve(import.meta.dir, "../src/index.ts");
export const pkgPath = resolve(import.meta.dir, "../package.json");
/** Extract the `value` field from a { type, value } envelope JSON string. */
export function envValue(json: string): unknown {
return (JSON.parse(json.trim()) as { value: unknown }).value;
}
/**
* Register a schema directly via the library (CLI schema put was removed).
* Returns the type hash.
*/
export async function putSchemaFile(
storePath: string,
schemaFilePath: string,
): Promise<string> {
const store = await openFsStore(storePath);
const schema = JSON.parse(
readFileSync(schemaFilePath, "utf-8"),
) as JSONSchema;
const hash = await putSchema(store, schema);
return hash;
}
/**
* Run CLI command. Accepts either a string[] or ...string[] (rest args).
* If first arg is an array, uses that as args. Otherwise treats all args as the command.
*/
export async function runCli(
args: string[],
storePath?: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const finalArgs = storePath
? ["bun", entrypoint, "--store", storePath, ...args]
: ["bun", entrypoint, ...args];
const proc = Bun.spawn(finalArgs, {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { stdout, stderr, exitCode };
}
export async function runCliWithStdin(
args: string[],
storePath: string,
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const finalArgs = ["bun", entrypoint, "--store", storePath, ...args];
const proc = Bun.spawn(finalArgs, {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
});
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { stdout, stderr, exitCode };
}
/**
* Parse JSON and strip volatile fields (timestamp, created, updated)
* so snapshots are stable across runs.
*/
export function stripVolatile(json: string): unknown {
const strip = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(strip);
if (v !== null && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (k === "timestamp" || k === "created" || k === "updated") continue;
out[k] = strip(val);
}
return out;
}
return v;
};
return strip(JSON.parse(json));
}
@@ -1,156 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BOOTSTRAP_STORE } from "@uncaged/json-cas";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
import { envValue, runCli } from "./helpers.js";
let storePath: string;
beforeEach(() => {
storePath = mkdtempSync(join(tmpdir(), "json-cas-list-meta-schema-"));
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
rmSync(storePath, { recursive: true, force: true });
});
describe("list-meta CLI command", () => {
test("E1. list-meta on bootstrapped store contains exactly the meta-schema hash", async () => {
// First, get @schema hash by calling has on it (also triggers bootstrap)
const { stdout: hashOut, exitCode: hashCode } = await runCli(
["hash", "@schema", "--pipe"],
storePath,
);
// ensure bootstrap by running a no-op command:
void hashOut;
void hashCode;
// Bootstrap fully via 'list --type @schema'
const { stdout: schemaListOut } = await runCli(
["list", "--type", "@schema"],
storePath,
);
const schemaList = envValue(schemaListOut) as string[];
expect(Array.isArray(schemaList)).toBe(true);
const { stdout, stderr, exitCode } = await runCli(["list-meta"], storePath);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(Array.isArray(parsed.value)).toBe(true);
expect(parsed.value).toHaveLength(1);
expect(parsed.value[0]).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("E1. --json flag yields compact JSON", async () => {
const { stdout, exitCode } = await runCli(
["--json", "list-meta"],
storePath,
);
expect(exitCode).toBe(0);
// compact = no newlines/spaces between fields
expect(stdout.trim()).not.toContain("\n ");
});
});
describe("list-schema CLI command", () => {
test("E2. list-schema on bootstrapped store includes meta-schema and built-ins", async () => {
const { stdout, stderr, exitCode } = await runCli(
["list-schema"],
storePath,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(Array.isArray(parsed.value)).toBe(true);
// meta + 5 primitive + 20 output = 26
expect(parsed.value.length).toBeGreaterThanOrEqual(6);
});
});
describe("usage help", () => {
test("E3. printing usage includes list-meta and list-schema lines", async () => {
const { stdout, exitCode } = await runCli([], storePath);
expect(exitCode).toBe(0);
expect(stdout).toContain("list-meta");
expect(stdout).toContain("list-schema");
});
});
describe("F1. output schemas registered", () => {
test("@output/list-meta and @output/list-schema schemas exist", async () => {
const { stdout, exitCode } = await runCli(["list-meta"], storePath);
expect(exitCode).toBe(0);
const parsed = JSON.parse(stdout) as { type: string };
// type hash references the @output/list-meta schema, must be retrievable
const { stdout: getOut, exitCode: getCode } = await runCli(
["get", parsed.type],
storePath,
);
expect(getCode).toBe(0);
const node = envValue(getOut) as {
payload: { title?: string };
};
expect(node.payload.title).toBe("ucas list-meta result");
const { stdout: schemaOut } = await runCli(["list-schema"], storePath);
const schemaParsed = JSON.parse(schemaOut) as { type: string };
const { stdout: getSchOut, exitCode: getSchCode } = await runCli(
["get", schemaParsed.type],
storePath,
);
expect(getSchCode).toBe(0);
const schNode = envValue(getSchOut) as {
payload: { title?: string };
};
expect(schNode.payload.title).toBe("ucas list-schema result");
});
});
describe("E4. list-schema vs list --type with multiple meta-schema versions", () => {
test("list-schema includes schemas typed by older meta-schemas; list --type @schema does not", async () => {
// Set up two distinct meta-schemas via the library
const store = await openFsStore(storePath);
const m1 = await store[BOOTSTRAP_STORE]({
type: "object",
title: "meta-v1",
});
const m2 = await store[BOOTSTRAP_STORE]({
type: "object",
title: "meta-v2",
});
// schema typed by older meta M1
const sM1 = await store.put(m1, { type: "string" });
expect(m1).not.toBe(m2);
// CLI: list-schema must include sM1
const { stdout: lsOut, exitCode: lsCode } = await runCli(
["list-schema"],
storePath,
);
expect(lsCode).toBe(0);
const lsValue = envValue(lsOut) as string[];
expect(lsValue).toContain(sM1);
expect(lsValue).toContain(m1);
expect(lsValue).toContain(m2);
// CLI: list --type <M2 hash> must NOT include sM1
const { stdout: ltOut, exitCode: ltCode } = await runCli(
["list", "--type", m2],
storePath,
);
expect(ltCode).toBe(0);
const ltValue = envValue(ltOut) as string[];
expect(ltValue).not.toContain(sM1);
// list-schema.value.length > list --type <newest>.value.length
expect(lsValue.length).toBeGreaterThan(ltValue.length);
});
});
-214
View File
@@ -1,214 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
// Set up template for render tests
const tmplFile = join(tmpStore, "render-template.liquid");
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
await runCli(["template", "set", typeHash, tmplFile]);
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
async function runCliWithStdin(
args: string[],
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
// ---- Phase 8: Pipe Composition ----
describe("Phase 8: Pipe Composition", () => {
test("8.1 put | render -p expands the stored hash to its content", async () => {
const nodeFile = join(tmpStore, "pipe-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Bob", age: 42 }));
const { stdout: putOut, exitCode: putExit } = await runCli([
"put",
typeHash,
nodeFile,
]);
expect(putExit).toBe(0);
// The put envelope value is a cas_ref hash; render -p dereferences it and
// renders the stored node's payload.
const { stdout, exitCode } = await runCliWithStdin(
["render", "--pipe"],
putOut,
);
expect(exitCode).toBe(0);
expect(stdout).toContain("Bob");
});
test("8.3 list --type @schema emits a parseable envelope of hashes", async () => {
const { stdout, exitCode } = await runCli(["list", "--type", "@schema"]);
expect(exitCode).toBe(0);
// Downstream consumers (jq, etc.) read the `value` array of hashes.
const value = envValue(stdout) as string[];
expect(Array.isArray(value)).toBe(true);
for (const hash of value) {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("8.4 list --type @schema | render -p expands the schema list", async () => {
const { stdout: listOut } = await runCli(["list", "--type", "@schema"]);
// list result items are cas_ref hashes; render -p dereferences each one
// and renders the schema contents.
const { stdout, exitCode } = await runCliWithStdin(
["render", "--pipe"],
listOut,
);
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
test("8.5 render <hash> uses a registered template", async () => {
// Register a template for the schema, then render a fresh node by hash.
const tmplFile = join(tmpStore, "pipe-render.liquid");
writeFileSync(tmplFile, "Person: {{ payload.name }} ({{ payload.age }})");
const { exitCode: setExit } = await runCli([
"template",
"set",
typeHash,
tmplFile,
]);
expect(setExit).toBe(0);
const nodeFile = join(tmpStore, "pipe-render-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Carol", age: 25 }));
const { stdout: putOut } = await runCli(["put", typeHash, nodeFile]);
const freshHash = envValue(putOut) as string;
const { stdout, exitCode } = await runCli(["render", freshHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Person: Carol (25)");
});
});
// ---- Phase 9: Put/Hash Pipe Input ----
describe("Phase 9: Put/Hash Pipe Input", () => {
test("9.1 put -p reads JSON from stdin and stores node", async () => {
const payload = JSON.stringify({ name: "PipeAlice", age: 99 });
const { stdout, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
payload,
);
expect(exitCode).toBe(0);
const hash = envValue(stdout);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Verify stored correctly
const { stdout: getOut } = await runCli(["get", hash as string]);
expect(getOut).toContain("PipeAlice");
});
test("9.2 hash -p reads JSON from stdin and computes hash without storing", async () => {
const payload = JSON.stringify({ name: "PipeBob", age: 55 });
const { stdout, exitCode } = await runCliWithStdin(
["hash", typeHash, "-p"],
payload,
);
expect(exitCode).toBe(0);
const hash = envValue(stdout);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Should NOT be stored
const { exitCode: hasExit, stdout: hasOut } = await runCli([
"has",
hash as string,
]);
expect(hasExit).toBe(0);
expect(envValue(hasOut)).toBe(false);
});
test("9.3 put -p with file arg errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "some-file.json", "-p"],
"{}",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Cannot use --pipe/-p with a file argument");
});
test("9.4 put -p with empty stdin errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
"",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("No input on stdin");
});
test("9.5 put -p with invalid JSON errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
"not json",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Invalid JSON on stdin");
});
});
@@ -1,101 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
describe("Phase 1: CAS Core", () => {
test("1.1 init + put with @object bootstraps store", async () => {
expect(typeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("1.5 put returns node hash", async () => {
expect(nodeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("1.6 get returns node JSON (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["get", nodeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.7 has returns true for existing node", async () => {
const { stdout, exitCode } = await runCli(["has", nodeHash]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe(true);
});
test("1.8 has returns false for non-existing hash", async () => {
const { stdout, exitCode } = await runCli(["has", "AAAAAAAAAAAAA"]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe(false);
});
test("1.12 hash dry-run returns same hash as put", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stdout, exitCode } = await runCli(["hash", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe(nodeHash);
});
test("1.13 list --type returns nodes of that type", async () => {
const { stdout, exitCode } = await runCli(["list", "--type", typeHash]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toContain(nodeHash);
});
});
-521
View File
@@ -1,521 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { bootstrap } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { envValue, putSchemaFile, runCli, runCliWithStdin } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
// --- Standalone render tests from cli.test.ts ---
describe("ucas render command", () => {
test("R1: render requires hash argument", async () => {
const { exitCode, stderr } = await runCli(["render"]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Usage");
});
test("R2: render with missing hash shows error", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const { exitCode, stderr } = await runCli(
["render", "ZZZZZZZZZZZZZ"],
tmpStore,
);
// Missing hash should exit with error
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Node not found");
expect(stderr).toContain("ZZZZZZZZZZZZZ");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R3: render with invalid numeric flag fails", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const { exitCode, stderr } = await runCli(
["render", "AAAAAAAAAAAAA", "--resolution", "invalid"],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("valid number");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
// --- e2e Phase 5: Render ---
describe("Phase 5: Render", () => {
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
async function runCliE2e(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
async function runCliE2eWithStdin(
args: string[],
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCliE2e(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
// Register template for render tests
const tmplFile = join(tmpStore, "render-template.liquid");
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
await runCliE2e(["template", "set", typeHash, tmplFile]);
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
test("5.1 render fills payload variables", async () => {
const { stdout, exitCode } = await runCliE2e(["render", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Hello Alice!");
expect(stdout).toMatchSnapshot();
});
test("5.2 render --resolution with different value", async () => {
const { stdout, exitCode } = await runCliE2e([
"render",
nodeHash,
"--resolution",
"0.5",
]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("5.3 render non-existent hash fails with error", async () => {
const { stderr, exitCode } = await runCliE2e(["render", "ZZZZZZZZZZZZZ"]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Node not found");
expect(stderr).toContain("ZZZZZZZZZZZZZ");
});
});
// --- Suite 6: CLI Integration with Templates (from cli.test.ts) ---
describe("Suite 6: CLI Integration with Templates", () => {
test("6.1 CLI with Template (Default Parameters)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
// Initialize store
await runCli(["init"], tmpStore);
// Create schema
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Create node
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice" }));
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// Create template file (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(templateFile, JSON.stringify("Hello {{ payload.name }}!"));
const { stdout: tmplOut } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
const tmplHash = envValue(tmplOut) as string;
// Register template
await runCli(
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
// Render with template
const { stdout: output, exitCode } = await runCli(
["render", nodeHash],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toBe("Hello Alice!");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.2 CLI with Template + Custom Decay", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Create schema with child ref
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
value: { type: "string" },
child: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Create child node
const childFile = join(tmpStore, "child.json");
writeFileSync(childFile, JSON.stringify({ value: "child", child: null }));
const { stdout: childOut } = await runCli(
["put", schemaHash.trim(), childFile],
tmpStore,
);
const childHash = envValue(childOut) as string;
// Create parent node
const parentFile = join(tmpStore, "parent.json");
writeFileSync(
parentFile,
JSON.stringify({ value: "parent", child: childHash }),
);
const { stdout: parentOut } = await runCli(
["put", schemaHash.trim(), parentFile],
tmpStore,
);
const parentHash = envValue(parentOut) as string;
// Create template showing resolution (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(
templateFile,
JSON.stringify("{{ payload.value }}(res={{ resolution }})"),
);
const { stdout: tmplOut } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
const tmplHash = envValue(tmplOut) as string;
// Register template
await runCli(
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
// Render with custom decay
const { stdout: output, exitCode } = await runCli(
["render", parentHash, "--decay", "0.7"],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toContain("parent(res=1)");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.3 CLI with Template + All Parameters", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Bob" }));
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// Create template (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(
templateFile,
JSON.stringify("Greetings {{ payload.name }}!"),
);
const { stdout: tmplOut } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
const tmplHash = envValue(tmplOut) as string;
await runCli(
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
const { stdout: output, exitCode } = await runCli(
[
"render",
nodeHash,
"--resolution",
"0.8",
"--decay",
"0.6",
"--epsilon",
"0.005",
],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toBe("Greetings Bob!");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.4 CLI with Non-templated Node (YAML Fallback)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" }));
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// No template registered - should fall back to YAML
const { stdout: output, exitCode } = await runCli(
["render", nodeHash],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toContain("name:");
expect(output).toContain("Charlie");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.5 CLI Error: Invalid Decay Value", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Test" }));
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
const { exitCode, stderr } = await runCli(
["render", nodeHash, "--decay", "1.5"],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("decay");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R8: render with non-existent hash exits with error", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const { exitCode, stderr, stdout } = await runCli(
["render", "AAAAAAAAAAAAA"],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Node not found");
expect(stderr).toContain("AAAAAAAAAAAAA");
expect(stdout).toBe("");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R9: render with valid hash exits successfully", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Get @string type hash via bootstrap
const store = createFsStore(tmpStore);
const types = await bootstrap(store);
const stringType = types["@string"];
// Create and store a simple string node
const nodeFile = join(tmpStore, "test.json");
writeFileSync(nodeFile, JSON.stringify("hello world"));
const { stdout: nodeOut } = await runCli(
["put", stringType, nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// Render the valid hash
const { exitCode, stdout, stderr } = await runCli(
["render", nodeHash],
tmpStore,
);
expect(exitCode).toBe(0);
expect(stdout).toContain("hello world");
expect(stderr).toBe("");
expect(stdout).not.toContain("Error");
expect(stdout).not.toContain("Node not found");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R10: render --pipe with valid envelope succeeds", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Get @string type hash via bootstrap
const store = createFsStore(tmpStore);
const types = await bootstrap(store);
const stringType = types["@string"];
// Create envelope and pipe to render
const envelope = JSON.stringify({ type: stringType, value: "test" });
const { exitCode, stdout, stderr } = await runCliWithStdin(
["render", "--pipe"],
tmpStore,
envelope,
);
expect(exitCode).toBe(0);
expect(stdout).toContain("test");
expect(stderr).toBe("");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R11: render --pipe with invalid type hash still renders", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Use invalid type hash in envelope
const envelope = JSON.stringify({
type: "ZZZZZZZZZZZZZ",
value: "test",
});
const { exitCode, stdout, stderr } = await runCliWithStdin(
["render", "--pipe"],
tmpStore,
envelope,
);
expect(exitCode).toBe(0);
expect(stdout).toContain("test");
expect(stderr).toBe("");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
// Store validation tests removed - with auto-bootstrap in Phase 1a,
// stores are automatically created and bootstrapped when opened.
// Issue #55 validation is no longer applicable.
@@ -1,659 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { envValue, putSchemaFile, runCli } from "./helpers";
// ---- Issue #50: Schema Validation in put Command ----
describe("Issue #50: Schema Validation in put", () => {
describe("Test Group 1: Valid Data (Regression Tests)", () => {
test("T1.1: Valid data matching schema should be accepted", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Create schema with required name property
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Create valid payload
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
const { stdout, stderr, exitCode } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Verify node was stored
const hash = envValue(stdout) as string;
const { exitCode: hasExitCode } = await runCli(["has", hash], tmpStore);
expect(hasExitCode).toBe(0);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T1.2: Valid data with optional properties should be accepted", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with optional property
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with only required properties
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
const { exitCode, stdout } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T1.3: Valid data with nested objects should be accepted", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with nested structure
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
address: {
type: "object",
properties: {
street: { type: "string" },
city: { type: "string" },
},
},
},
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with nested structure
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(
payloadFile,
JSON.stringify({
name: "test",
address: { street: "123 Main", city: "NYC" },
}),
);
const { exitCode, stdout } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T1.4: Valid data using @ alias for type-hash should be accepted", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify("hello world"));
const { exitCode, stdout } = await runCli(
["put", "@string", payloadFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
describe("Test Group 2: Type Mismatches (New Validation)", () => {
test("T2.1: Wrong property type should be rejected", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with name as string
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with name as number
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
const { exitCode, stdout, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).toBe(1);
expect(stdout).toBe("");
expect(stderr).toContain("Validation failed");
expect(stderr).toContain(schemaHash.trim());
expect(stderr).toContain(payloadFile);
// Verify no node was stored
const { stdout: hasOutput } = await runCli(
["has", "0000000000000"],
tmpStore,
);
expect(envValue(hasOutput)).toBe(false);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T2.2: Missing required property should be rejected", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with required name
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Empty payload
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({}));
const { exitCode, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Validation failed");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T2.3: Additional properties when disallowed should be rejected", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with additionalProperties: false
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with extra property
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(
payloadFile,
JSON.stringify({ name: "test", extra: "not allowed" }),
);
const { exitCode, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Validation failed");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T2.4: Wrong root type should be rejected", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema expecting array
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "array",
items: { type: "string" },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload is an object
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({}));
const { exitCode, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Validation failed");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T2.5: Nested type mismatch should be rejected", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with nested object
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
user: {
type: "object",
properties: {
age: { type: "number" },
},
},
},
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with wrong nested type
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(
payloadFile,
JSON.stringify({ user: { age: "not a number" } }),
);
const { exitCode, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Validation failed");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
describe("Test Group 3: Schema Errors (Edge Cases)", () => {
test("T3.1: Non-existent type-hash should fail gracefully", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
const { exitCode, stderr } = await runCli(
["put", "ZZZZZZZZZZZZZ", payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Schema not found");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T3.3: Invalid @ alias should fail before validation", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({}));
const { exitCode, stderr } = await runCli(
["put", "@nonexistent", payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Schema not found");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
describe("Test Group 4: Integration with Existing Features", () => {
test("T4.1: Hash command should not validate (dry-run consistency)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Create schema
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Invalid payload
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
// Hash command should succeed even with invalid data
const { exitCode, stdout } = await runCli(
["hash", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T4.2: Validation respects cas_ref format in schemas", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with cas_ref format
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
ref: { type: "string", format: "cas_ref" },
},
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Valid cas_ref
const validFile = join(tmpStore, "valid.json");
writeFileSync(validFile, JSON.stringify({ ref: "0000000000000" }));
const { exitCode: validExitCode } = await runCli(
["put", schemaHash.trim(), validFile],
tmpStore,
);
expect(validExitCode).toBe(0);
// Invalid cas_ref (wrong length)
const invalidFile = join(tmpStore, "invalid.json");
writeFileSync(invalidFile, JSON.stringify({ ref: "short" }));
const { exitCode: invalidExitCode, stderr } = await runCli(
["put", schemaHash.trim(), invalidFile],
tmpStore,
);
expect(invalidExitCode).not.toBe(0);
expect(stderr).toContain("Validation failed");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T4.3: Schema self-validation still works", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Valid schema
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const hash = await putSchemaFile(tmpStore, schemaFile);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
describe("Test Group 5: Error Message Quality", () => {
test("T5.1: Error message should be helpful", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
const { stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(stderr).toContain("Validation failed");
expect(stderr).toContain(schemaHash.trim());
expect(stderr).toContain(payloadFile);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T5.2: Error should go to stderr, not stdout", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
const { stdout, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(stdout).toBe("");
expect(stderr.length).toBeGreaterThan(0);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
});
// e2e Phase 2 tests
describe("Phase 2: Schema Validation", () => {
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"put",
typeHash,
nodeFile,
],
{ stdout: "pipe", stderr: "pipe" },
);
await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
nodeHash = envValue(stdout) as string;
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => {
const badFile = join(tmpStore, "bad-node.json");
writeFileSync(badFile, JSON.stringify({ name: 123 }));
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"put",
typeHash,
badFile,
],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
expect(exitCode).not.toBe(0);
expect(stdout).toBe("");
expect(stderr).toContain("Validation failed");
expect(stderr).toContain(typeHash);
});
test("2.3 put against non-existent schema hash fails", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"put",
"AAAAAAAAAAAAA",
nodeFile,
],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stderr = (await new Response(proc.stderr).text()).trim();
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
@@ -1,85 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
describe("Phase 1: CAS Core", () => {
test("1.9 verify returns ok for valid node", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.10 refs lists direct references (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["refs", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.11 walk shows traversal tree (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["walk", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
});
describe("Phase 2: Schema Validation", () => {
test("2.2 verify on valid node returns ok (hash + schema)", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe("ok");
});
});
-27
View File
@@ -1,32 +1,5 @@
# @uncaged/json-cas-fs
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.6.0
## 0.5.3
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas-fs",
"version": "0.6.0",
"version": "0.5.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -16,10 +16,10 @@
],
"scripts": {
"test": "bun test",
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"@uncaged/json-cas": "^0.6.0",
"@uncaged/json-cas": "^0.5.3",
"cborg": "^4.2.3"
}
}
+1 -1
View File
@@ -1 +1 @@
export { createFsStore, openStore } from "./store.js";
export { createFsStore } from "./store.js";
+3 -249
View File
@@ -1,24 +1,16 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasNode } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
bootstrap,
computeHash,
computeSelfHash,
verify,
} from "@uncaged/json-cas";
import { createFsStore, openStore } from "./store.js";
import { createFsStore } from "./store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "json-cas-fs-test-"));
@@ -67,7 +59,7 @@ describe("createFsStore – init and bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(26);
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
});
});
@@ -320,241 +312,3 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// openStore – async with auto-bootstrap
// ──────────────────────────────────────────────────────────────────────────────
describe("openStore – async with auto-bootstrap", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("openStore returns Promise<Store>", async () => {
const store = await openStore(dir);
expect(store).toBeDefined();
expect(typeof store.put).toBe("function");
expect(typeof store.get).toBe("function");
});
test("openStore auto-creates directory when it doesn't exist", async () => {
const nested = join(dir, "sub", "nested", "store");
expect(existsSync(nested)).toBe(false);
const store = await openStore(nested);
expect(existsSync(nested)).toBe(true);
// Verify store works
const typeHash = await computeSelfHash({ name: "t" });
const hash = await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
});
test("openStore works when directory already exists", async () => {
// Pre-create the directory
const store1 = await openStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
await store1.put(typeHash, { x: 1 });
// Open again
const store2 = await openStore(dir);
expect(store2.listByType(typeHash)).toHaveLength(1);
});
test("openStore throws error when path exists but is not a directory", async () => {
const filePath = join(dir, "not-a-dir");
writeFileSync(filePath, "test");
await expect(openStore(filePath)).rejects.toThrow();
});
test("openStore auto-bootstraps on first open (empty directory)", async () => {
const store = await openStore(dir);
// Check that bootstrap schemas exist
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
expect(metaHash).toBeDefined();
expect(store.has(metaHash as string)).toBe(true);
// Verify all core schemas exist
expect(store.has(builtinSchemas["@string"] as string)).toBe(true);
expect(store.has(builtinSchemas["@number"] as string)).toBe(true);
expect(store.has(builtinSchemas["@object"] as string)).toBe(true);
expect(store.has(builtinSchemas["@array"] as string)).toBe(true);
expect(store.has(builtinSchemas["@bool"] as string)).toBe(true);
expect(store.has(builtinSchemas["@schema"] as string)).toBe(true);
});
test("openStore bootstrap is idempotent on subsequent opens", async () => {
const store1 = await openStore(dir);
const schemas1 = await bootstrap(store1);
const count1 = store1.listAll().length;
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
const count2 = store2.listAll().length;
// Same schemas, same count
expect(schemas1).toEqual(schemas2);
expect(count1).toBe(count2);
});
test("openStore works on already-bootstrapped store", async () => {
// Bootstrap manually first
const store1 = createFsStore(dir);
const schemas1 = await bootstrap(store1);
// Open with openStore
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
expect(schemas1).toEqual(schemas2);
});
test("openStore auto-bootstraps old store without bootstrap", async () => {
// Create a store with some data but no bootstrap
const store1 = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "custom" });
await store1.put(typeHash, { data: "old" });
// Open with openStore - should auto-bootstrap
const store2 = await openStore(dir);
const schemas = await bootstrap(store2);
expect(store2.has(schemas["@schema"] as string)).toBe(true);
// Old data still exists
expect(store2.listByType(typeHash)).toHaveLength(1);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// listMeta and listSchemas (FS persistence)
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – listMeta and listSchemas", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "json-cas-fs-meta-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("C1. _index/_meta exists and contains hash after self-referencing put", async () => {
const store = createFsStore(dir);
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
const metaPath = join(dir, "_index", "_meta");
expect(existsSync(metaPath)).toBe(true);
const content = readFileSync(metaPath, "utf8");
expect(content).toBe(`${hash}\n`);
});
test("C2. multiple meta puts append, no duplicates", async () => {
const store = createFsStore(dir);
const h1 = await store[BOOTSTRAP_STORE]({ type: "object", v: 1 });
const h2 = await store[BOOTSTRAP_STORE]({ type: "object", v: 2 });
// re-put first (idempotent)
await store[BOOTSTRAP_STORE]({ type: "object", v: 1 });
const content = readFileSync(join(dir, "_index", "_meta"), "utf8");
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines).toHaveLength(2);
expect(lines).toContain(h1);
expect(lines).toContain(h2);
});
test("C3. reload from disk preserves listMeta", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "a" });
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "b" });
const store2 = createFsStore(dir);
const meta = store2.listMeta();
expect(meta).toContain(h1);
expect(meta).toContain(h2);
expect(meta).toHaveLength(2);
});
test("C4. migrates from existing nodes when _meta is missing", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "mig" });
const t = await computeSelfHash({ name: "regular" });
await store1.put(h1, { type: "string" });
// Remove _index/_meta but keep _index/* and .bin files
const metaPath = join(dir, "_index", "_meta");
rmSync(metaPath, { force: true });
expect(existsSync(metaPath)).toBe(false);
const store2 = createFsStore(dir);
expect(store2.listMeta()).toContain(h1);
expect(existsSync(metaPath)).toBe(true);
const content = readFileSync(metaPath, "utf8");
expect(content).toContain(h1);
// unrelated type hash not in meta
expect(store2.listMeta()).not.toContain(t);
});
test("C5. existing _meta is not overwritten", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "keep" });
const metaPath = join(dir, "_index", "_meta");
const before = readFileSync(metaPath, "utf8");
// Reopen and confirm content unchanged
const _store2 = createFsStore(dir);
const after = readFileSync(metaPath, "utf8");
expect(after).toBe(before);
expect(after).toContain(h1);
});
test("C6. listSchemas returns union of typeIndex[m] for m in metaSet", async () => {
const store = createFsStore(dir);
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s1 = await store.put(m, { type: "string" });
const s2 = await store.put(m, { type: "number" });
const s3 = await store.put(m, { type: "array" });
const schemas = store.listSchemas();
expect(schemas).toHaveLength(4);
expect(schemas).toContain(m);
expect(schemas).toContain(s1);
expect(schemas).toContain(s2);
expect(schemas).toContain(s3);
});
test("C7. delete persists removal from _meta", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: 1 });
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: 2 });
store1.delete(h1);
const metaPath = join(dir, "_index", "_meta");
const content = readFileSync(metaPath, "utf8");
expect(content).not.toContain(h1);
expect(content).toContain(h2);
const store2 = createFsStore(dir);
expect(store2.listMeta()).not.toContain(h1);
expect(store2.listMeta()).toContain(h2);
});
test("C8. fresh store with no self-ref puts has empty listMeta", () => {
const store = createFsStore(dir);
expect(store.listMeta()).toEqual([]);
// _meta may be absent; that's fine
const metaPath = join(dir, "_index", "_meta");
if (existsSync(metaPath)) {
const content = readFileSync(metaPath, "utf8");
expect(content).toBe("");
}
});
});
-135
View File
@@ -5,7 +5,6 @@ import {
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
@@ -14,7 +13,6 @@ import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
bootstrap,
cborEncode,
computeHash,
computeSelfHash,
@@ -22,7 +20,6 @@ import {
import { decode } from "cborg";
const INDEX_DIR = "_index";
const META_FILE = "_meta";
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
let entries: string[];
@@ -101,61 +98,6 @@ function loadOrMigrateTypeIndex(
return loadTypeIndex(indexDir);
}
function loadOrMigrateMetaSet(
dir: string,
data: Map<Hash, CasNode>,
): Set<Hash> {
const indexDir = join(dir, INDEX_DIR);
const metaPath = join(indexDir, META_FILE);
if (existsSync(metaPath)) {
try {
const content = readFileSync(metaPath, "utf8");
return new Set(parseIndexFile(content));
} catch {
return new Set();
}
}
// Migration: scan loaded nodes for self-referencing nodes (type === hash)
const metaSet = new Set<Hash>();
for (const [hash, node] of data) {
if (node.type === hash) {
metaSet.add(hash);
}
}
if (metaSet.size > 0) {
mkdirSync(indexDir, { recursive: true });
const body = `${[...metaSet].join("\n")}\n`;
writeFileSync(metaPath, body, "utf8");
}
return metaSet;
}
function appendToMetaSet(
indexDir: string,
metaSet: Set<Hash>,
hash: Hash,
): void {
if (metaSet.has(hash)) return;
metaSet.add(hash);
mkdirSync(indexDir, { recursive: true });
appendFileSync(join(indexDir, META_FILE), `${hash}\n`, "utf8");
}
function rewriteMetaSet(indexDir: string, metaSet: Set<Hash>): void {
const metaPath = join(indexDir, META_FILE);
if (metaSet.size === 0) {
try {
unlinkSync(metaPath);
} catch {
// ignore
}
return;
}
mkdirSync(indexDir, { recursive: true });
const body = `${[...metaSet].join("\n")}\n`;
writeFileSync(metaPath, body, "utf8");
}
function appendToTypeIndex(
indexDir: string,
typeIndex: Map<Hash, Hash[]>,
@@ -174,7 +116,6 @@ export function createFsStore(dir: string): BootstrapCapableStore {
loadDir(dir, data);
const indexDir = join(dir, INDEX_DIR);
const typeIndex = loadOrMigrateTypeIndex(dir, data);
const metaSet = loadOrMigrateMetaSet(dir, data);
async function putSelfReferencing(payload: unknown): Promise<Hash> {
const hash = await computeSelfHash(payload);
@@ -193,7 +134,6 @@ export function createFsStore(dir: string): BootstrapCapableStore {
appendToTypeIndex(indexDir, typeIndex, hash, hash);
}
appendToMetaSet(indexDir, metaSet, hash);
return hash;
}
@@ -240,22 +180,6 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return Array.from(data.keys());
},
listMeta(): Hash[] {
return Array.from(metaSet);
},
listSchemas(): Hash[] {
const result = new Set<Hash>();
for (const meta of metaSet) {
result.add(meta);
const list = typeIndex.get(meta);
if (list) {
for (const h of list) result.add(h);
}
}
return Array.from(result);
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
@@ -287,11 +211,6 @@ export function createFsStore(dir: string): BootstrapCapableStore {
writeFileSync(join(indexDir, node.type), body, "utf8");
}
}
// Remove from meta set if applicable
if (metaSet.has(hash)) {
metaSet.delete(hash);
rewriteMetaSet(indexDir, metaSet);
}
}
},
@@ -300,57 +219,3 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return store;
}
/**
* Open a filesystem-backed CAS store with automatic directory creation and bootstrap.
* This is an async function that:
* 1. Creates the directory (with recursive: true) if it doesn't exist
* 2. Validates that the path is actually a directory (not a file)
* 3. Creates the store
* 4. Runs bootstrap (which is idempotent)
*
* @param dir - The directory path for the store
* @returns A Promise resolving to the BootstrapCapableStore
* @throws Error if the path exists but is not a directory
*/
export async function openStore(dir: string): Promise<BootstrapCapableStore> {
// Create directory if it doesn't exist
try {
mkdirSync(dir, { recursive: true });
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "EACCES") {
throw new Error(`Permission denied: cannot access store at ${dir}`);
}
if (nodeError.code === "ENOTDIR") {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
}
throw error;
}
// Validate that the path is a directory
try {
const stats = statSync(dir);
if (!stats.isDirectory()) {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
throw new Error(`Store not found at ${dir}`);
}
}
throw error;
}
// Create the store
const store = createFsStore(dir);
// Bootstrap (idempotent)
await bootstrap(store);
return store;
}
-22
View File
@@ -1,27 +1,5 @@
# @uncaged/json-cas
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
## 0.5.3
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas",
"version": "0.6.0",
"version": "0.5.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -16,7 +16,7 @@
],
"scripts": {
"test": "bun test",
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"ajv": "^8.20.0",
+2 -213
View File
@@ -1,42 +1,18 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import type { JSONSchema } from "./schema.js";
import { getSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
const OUTPUT_ALIASES = [
"@output/put",
"@output/get",
"@output/has",
"@output/hash",
"@output/verify",
"@output/refs",
"@output/walk",
"@output/list",
"@output/list-meta",
"@output/list-schema",
"@output/var-set",
"@output/var-get",
"@output/var-delete",
"@output/var-tag",
"@output/var-list",
"@output/template-set",
"@output/template-get",
"@output/template-list",
"@output/template-delete",
"@output/gc",
] as const;
// ──────────────────────────────────────────────────────────────────────────────
// Built-in Schema Registration Tests
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of 26 built-in schema aliases to hashes", async () => {
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 primitive + 20 output aliases = 26
// Should return object with 6 aliases
expect(builtinSchemas).toHaveProperty("@schema");
expect(builtinSchemas).toHaveProperty("@string");
expect(builtinSchemas).toHaveProperty("@number");
@@ -44,12 +20,6 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty("@array");
expect(builtinSchemas).toHaveProperty("@bool");
for (const alias of OUTPUT_ALIASES) {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(26);
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
expect(typeof hash).toBe("string");
@@ -157,184 +127,3 @@ describe("bootstrap - Built-in Schemas", () => {
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// @output/* Schema Registration Tests
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - @output/* Schemas", () => {
test("each @output/* schema has a title", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
for (const alias of OUTPUT_ALIASES) {
const hash = aliases[alias];
if (!hash) throw new Error(`${alias} not found`);
const schema = getSchema(store, hash) as JSONSchema;
expect(schema).not.toBeNull();
expect(typeof schema.title).toBe("string");
expect((schema.title as string).startsWith("ucas ")).toBe(true);
}
});
test("@output/put schema describes a cas_ref string", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/put"];
if (!hash) throw new Error("@output/put not found");
const schema = getSchema(store, hash);
expect(schema).toEqual({
type: "string",
format: "cas_ref",
title: "ucas put result",
});
});
test("@output/get schema describes object with type, payload, timestamp", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/get"];
if (!hash) throw new Error("@output/get not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas get result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.type).toEqual({ type: "string", format: "cas_ref" });
expect(props.payload).toEqual({});
expect(props.timestamp).toEqual({ type: "number" });
});
test("@output/has schema describes a boolean", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/has"];
if (!hash) throw new Error("@output/has not found");
expect(getSchema(store, hash)).toEqual({
type: "boolean",
title: "ucas has result",
});
});
test("@output/verify schema describes enum of ok|corrupted|invalid", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/verify"];
if (!hash) throw new Error("@output/verify not found");
const schema = getSchema(store, hash);
expect(schema).toEqual({
type: "string",
enum: ["ok", "corrupted", "invalid"],
title: "ucas verify result",
});
});
test("@output/refs schema describes array of cas_ref strings", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/refs"];
if (!hash) throw new Error("@output/refs not found");
expect(getSchema(store, hash)).toEqual({
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas refs result",
});
});
test("@output/gc schema describes object with gc stats fields", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/gc"];
if (!hash) throw new Error("@output/gc not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas gc result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.total).toEqual({ type: "number" });
expect(props.reachable).toEqual({ type: "number" });
expect(props.collected).toEqual({ type: "number" });
expect(props.scanned).toEqual({ type: "number" });
});
test("@output/var-set schema describes a Variable object", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/var-set"];
if (!hash) throw new Error("@output/var-set not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas var set result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.name).toEqual({ type: "string" });
expect(props.schema).toEqual({ type: "string", format: "cas_ref" });
expect(props.value).toEqual({ type: "string", format: "cas_ref" });
});
test("@output/var-list schema describes array of Variable objects", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/var-list"];
if (!hash) throw new Error("@output/var-list not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("array");
expect(schema.title).toBe("ucas var list result");
const items = schema.items as JSONSchema;
expect(items.type).toBe("object");
const props = items.properties as Record<string, JSONSchema>;
expect(props.name).toEqual({ type: "string" });
});
test("@output/template-delete schema describes object with deleted boolean", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/template-delete"];
if (!hash) throw new Error("@output/template-delete not found");
expect(getSchema(store, hash)).toEqual({
type: "object",
properties: { deleted: { type: "boolean" } },
title: "ucas template delete result",
});
});
test("all @output/* schemas are distinct hashes", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const outputHashes = OUTPUT_ALIASES.map((alias) => aliases[alias]);
const uniqueHashes = new Set(outputHashes);
expect(uniqueHashes.size).toBe(OUTPUT_ALIASES.length);
});
});
describe("bootstrap - meta and schemas indexes (D1)", () => {
test("listMeta contains the bootstrap meta-schema hash", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const metaHash = aliases["@schema"];
expect(store.listMeta()).toContain(metaHash as string);
});
test("listSchemas contains meta-schema and all built-in schemas", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const schemas = store.listSchemas();
for (const [, hash] of Object.entries(aliases)) {
expect(schemas).toContain(hash);
}
expect(schemas.length).toBeGreaterThanOrEqual(6);
});
});
+3 -227
View File
@@ -60,230 +60,12 @@ const BOOTSTRAP_PAYLOAD = {
enum: { type: "array" },
const: {},
description: { type: "string" },
// P1 leaf constraints
minimum: { type: "number" },
maximum: { type: "number" },
exclusiveMinimum: { type: "number" },
exclusiveMaximum: { type: "number" },
minLength: { type: "number" },
maxLength: { type: "number" },
pattern: { type: "string" },
minItems: { type: "number" },
maxItems: { type: "number" },
uniqueItems: { type: "boolean" },
// P2 combinators + conditionals
allOf: {
type: "array",
items: { type: "object", additionalProperties: false },
},
if: { type: "object", additionalProperties: false },
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable
then: { type: "object", additionalProperties: false },
else: { type: "object", additionalProperties: false },
patternProperties: {
type: "object",
additionalProperties: { type: "object", additionalProperties: false },
},
prefixItems: {
type: "array",
items: { type: "object", additionalProperties: false },
},
// P2 leaf constraints
multipleOf: { type: "number" },
minProperties: { type: "number" },
maxProperties: { type: "number" },
default: {},
// P3 combinators
not: { type: "object", additionalProperties: false },
contains: { type: "object", additionalProperties: false },
propertyNames: { type: "object", additionalProperties: false },
// P3 metadata
examples: { type: "array" },
readOnly: { type: "boolean" },
writeOnly: { type: "boolean" },
deprecated: { type: "boolean" },
$comment: { type: "string" },
},
} as const;
const VARIABLE_PROPERTIES = {
name: { type: "string" },
schema: { type: "string", format: "cas_ref" },
value: { type: "string", format: "cas_ref" },
created: { type: "number" },
updated: { type: "number" },
tags: { type: "object" },
labels: { type: "array", items: { type: "string" } },
} as const;
const OUTPUT_SCHEMAS: ReadonlyArray<
readonly [alias: string, schema: Record<string, unknown>]
> = [
[
"@output/put",
{ type: "string", format: "cas_ref", title: "ucas put result" },
],
[
"@output/get",
{
type: "object",
properties: {
type: { type: "string", format: "cas_ref" },
payload: {},
timestamp: { type: "number" },
},
title: "ucas get result",
},
],
["@output/has", { type: "boolean", title: "ucas has result" }],
[
"@output/hash",
{ type: "string", format: "cas_ref", title: "ucas hash result" },
],
[
"@output/verify",
{
type: "string",
enum: ["ok", "corrupted", "invalid"],
title: "ucas verify result",
},
],
[
"@output/refs",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas refs result",
},
],
[
"@output/walk",
{
type: "array",
items: { type: "string" },
title: "ucas walk result",
},
],
[
"@output/list",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas list result",
},
],
[
"@output/list-meta",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas list-meta result",
},
],
[
"@output/list-schema",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas list-schema result",
},
],
[
"@output/var-set",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var set result",
},
],
[
"@output/var-get",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var get result",
},
],
[
"@output/var-delete",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var delete result",
},
],
[
"@output/var-tag",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var tag result",
},
],
[
"@output/var-list",
{
type: "array",
items: { type: "object", properties: { ...VARIABLE_PROPERTIES } },
title: "ucas var list result",
},
],
[
"@output/template-set",
{
type: "object",
properties: {
schemaHash: { type: "string", format: "cas_ref" },
contentHash: { type: "string", format: "cas_ref" },
},
title: "ucas template set result",
},
],
[
"@output/template-get",
{ type: "string", title: "ucas template get result" },
],
[
"@output/template-list",
{
type: "array",
items: {
type: "object",
properties: {
schemaHash: { type: "string", format: "cas_ref" },
contentHash: { type: "string", format: "cas_ref" },
},
},
title: "ucas template list result",
},
],
[
"@output/template-delete",
{
type: "object",
properties: { deleted: { type: "boolean" } },
title: "ucas template delete result",
},
],
[
"@output/gc",
{
type: "object",
properties: {
total: { type: "number" },
reachable: { type: "number" },
collected: { type: "number" },
scanned: { type: "number" },
},
title: "ucas gc result",
},
],
];
/**
* Write the meta-schema seed node into the store and register built-in schemas.
* The returned object contains aliases for the meta-schema, 5 primitive schemas,
* and 18 @output/* schemas (24 total).
* 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<Record<string, Hash>> {
@@ -301,8 +83,8 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
const arrayHash = await store.put(metaHash, { type: "array" });
const boolHash = await store.put(metaHash, { type: "boolean" });
// 3. Register @output/* schemas
const aliases: Record<string, Hash> = {
// 3. Return map of aliases to hashes
return {
"@schema": metaHash,
"@string": stringHash,
"@number": numberHash,
@@ -310,10 +92,4 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
"@array": arrayHash,
"@bool": boolHash,
};
for (const [alias, schema] of OUTPUT_SCHEMAS) {
aliases[alias] = await store.put(metaHash, schema);
}
return aliases;
}
+3 -5
View File
@@ -264,7 +264,7 @@ describe("bootstrap", () => {
);
});
test("returns a map with 26 built-in schema aliases", async () => {
test("returns a map with 6 built-in schema aliases", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
@@ -280,8 +280,6 @@ describe("bootstrap", () => {
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(Object.keys(builtinSchemas)).toHaveLength(26);
});
test("meta-schema node is stored and retrievable", async () => {
@@ -318,7 +316,7 @@ describe("bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
// All 26 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(26);
// All 6 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
});
});
-2
View File
@@ -5,7 +5,6 @@ export { cborEncode } from "./cbor.js";
export { type GcStats, gc } from "./gc.js";
export { computeHash, computeSelfHash } from "./hash.js";
export { renderWithTemplate } from "./liquid-render.js";
export { registerOutputTemplates } from "./output-templates.js";
export {
type RenderOptions,
render,
@@ -35,4 +34,3 @@ export {
VariableStore,
} from "./variable-store.js";
export { verify } from "./verify.js";
export { wrapEnvelope } from "./wrap-envelope.js";
-8
View File
@@ -31,14 +31,6 @@ export class MemStore implements BootstrapCapableStore {
return this.#inner.listAll();
}
listMeta(): Hash[] {
return this.#inner.listMeta();
}
listSchemas(): Hash[] {
return this.#inner.listSchemas();
}
delete(hash: Hash): void {
this.#inner.delete(hash);
}
@@ -1,117 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap } from "./bootstrap.js";
import { registerOutputTemplates } from "./output-templates.js";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
import { createVariableStore } from "./variable-store.js";
const OUTPUT_ALIASES = [
"@output/put",
"@output/get",
"@output/has",
"@output/hash",
"@output/verify",
"@output/refs",
"@output/walk",
"@output/list",
"@output/var-set",
"@output/var-get",
"@output/var-delete",
"@output/var-tag",
"@output/var-list",
"@output/template-set",
"@output/template-get",
"@output/template-list",
"@output/template-delete",
"@output/gc",
] as const;
describe("registerOutputTemplates", () => {
let store: Store;
let varStore: VariableStore;
let tempDir: string;
afterEach(async () => {
varStore.close();
await rm(tempDir, { recursive: true });
});
test("registers a template for every @output/* schema", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const registered = await registerOutputTemplates(store, varStore);
expect(Object.keys(registered)).toHaveLength(18);
for (const alias of OUTPUT_ALIASES) {
expect(registered).toHaveProperty(alias);
}
});
test("each template is retrievable via @ucas/template/text/<hash>", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
const aliases = await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
await registerOutputTemplates(store, varStore);
const stringHash = aliases["@string"];
if (!stringHash) throw new Error("@string not found");
for (const alias of OUTPUT_ALIASES) {
const schemaHash = aliases[alias];
if (!schemaHash) throw new Error(`${alias} not found`);
const varName = `@ucas/template/text/${schemaHash}`;
const variable = varStore.get(varName, stringHash);
if (variable === null) throw new Error(`Variable ${varName} not found`);
const templateNode = store.get(variable.value);
if (templateNode === null)
throw new Error(`Template node ${variable.value} not found`);
expect(typeof templateNode.payload).toBe("string");
}
});
test("is idempotent — safe to call multiple times", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const first = await registerOutputTemplates(store, varStore);
const second = await registerOutputTemplates(store, varStore);
expect(first).toEqual(second);
});
test("@output/put template contains payload reference", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
const aliases = await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
await registerOutputTemplates(store, varStore);
const putHash = aliases["@output/put"];
if (!putHash) throw new Error("@output/put not found");
const stringHash = aliases["@string"];
if (!stringHash) throw new Error("@string not found");
const variable = varStore.get(`@ucas/template/text/${putHash}`, stringHash);
if (variable === null)
throw new Error("@output/put template variable not found");
const templateNode = store.get(variable.value);
if (templateNode === null) throw new Error("Template node not found");
expect(templateNode.payload).toBe("{{ payload }}");
});
});
-87
View File
@@ -1,87 +0,0 @@
import { bootstrap } from "./bootstrap.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
const DEFAULT_TEMPLATES: ReadonlyArray<
readonly [alias: string, template: string]
> = [
["@output/put", "{{ payload }}"],
[
"@output/get",
"type: {{ payload.type }}\ntimestamp: {{ payload.timestamp }}",
],
["@output/has", "{{ payload }}"],
["@output/hash", "{{ payload }}"],
["@output/verify", "{{ payload }}"],
["@output/refs", "{% for ref in payload %}{{ ref }}\n{% endfor %}"],
["@output/walk", "{% for item in payload %}{{ item }}\n{% endfor %}"],
["@output/list", "{% for item in payload %}{{ item }}\n{% endfor %}"],
[
"@output/var-set",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-get",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-delete",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-tag",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-list",
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}",
],
[
"@output/template-set",
"schemaHash: {{ payload.schemaHash }}\ncontentHash: {{ payload.contentHash }}",
],
["@output/template-get", "{{ payload }}"],
[
"@output/template-list",
"{% for t in payload %}schemaHash: {{ t.schemaHash }}\ncontentHash: {{ t.contentHash }}\n{% endfor %}",
],
["@output/template-delete", "deleted: {{ payload.deleted }}"],
[
"@output/gc",
"total: {{ payload.total }}\nreachable: {{ payload.reachable }}\ncollected: {{ payload.collected }}\nscanned: {{ payload.scanned }}",
],
];
/**
* Register default LiquidJS templates for all @output/* schemas.
* Each template is stored as a @string CAS node and bound to
* the variable `@ucas/template/text/<schema-hash>`.
*
* Idempotent: safe to call multiple times.
*/
export async function registerOutputTemplates(
store: Store,
varStore: VariableStore,
): Promise<Record<string, Hash>> {
const aliases = await bootstrap(store);
const stringHash = aliases["@string"];
if (stringHash === undefined) {
throw new Error("@string schema not found in bootstrap result");
}
const registered: Record<string, Hash> = {};
for (const [alias, template] of DEFAULT_TEMPLATES) {
const schemaHash = aliases[alias];
if (schemaHash === undefined) {
throw new Error(`Schema alias not found: ${alias}`);
}
const contentHash = await store.put(stringHash, template);
const varName = `@ucas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
registered[alias] = contentHash;
}
return registered;
}
-350
View File
@@ -388,107 +388,6 @@ describe("bootstrap meta-schema self-reference", () => {
expect(dataNode.type).not.toBe(metaHash);
});
// ── P1 leaf constraints ──────────────────────────────────────────────────
test("accepts schema with numeric constraints (minimum/maximum)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "number",
minimum: 0,
maximum: 100,
exclusiveMinimum: -1,
exclusiveMaximum: 101,
});
expect(hash).toHaveLength(13);
// validate a conforming payload
const nodeHash = await store.put(hash, 42);
const node = store.get(nodeHash) as CasNode;
expect(validate(store, node)).toBe(true);
// validate a non-conforming payload
const badHash = await store.put(hash, 200);
const badNode = store.get(badHash) as CasNode;
expect(validate(store, badNode)).toBe(false);
});
test("accepts schema with string constraints (minLength/maxLength/pattern)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "string",
minLength: 1,
maxLength: 10,
pattern: "^[a-z]+$",
});
expect(hash).toHaveLength(13);
const goodHash = await store.put(hash, "hello");
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
const badHash = await store.put(hash, "HELLO");
expect(validate(store, store.get(badHash) as CasNode)).toBe(false);
});
test("accepts schema with array constraints (minItems/maxItems/uniqueItems)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "array",
items: { type: "number" },
minItems: 1,
maxItems: 3,
uniqueItems: true,
});
expect(hash).toHaveLength(13);
const goodHash = await store.put(hash, [1, 2, 3]);
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
const tooMany = await store.put(hash, [1, 2, 3, 4]);
expect(validate(store, store.get(tooMany) as CasNode)).toBe(false);
const dupes = await store.put(hash, [1, 1]);
expect(validate(store, store.get(dupes) as CasNode)).toBe(false);
});
test("rejects schema with wrong constraint types", async () => {
const store = createMemoryStore();
await expect(
putSchema(store, { type: "number", minimum: "zero" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { type: "string", maxLength: true } as never),
).rejects.toThrow();
await expect(
putSchema(store, { type: "array", uniqueItems: 1 } as never),
).rejects.toThrow();
});
test("accepts schema with nested property constraints", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 50 },
age: { type: "number", minimum: 0, maximum: 150 },
scores: {
type: "array",
items: { type: "number" },
minItems: 1,
uniqueItems: true,
},
},
required: ["name"],
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, {
name: "Alice",
age: 30,
scores: [95, 87],
});
expect(validate(store, store.get(good) as CasNode)).toBe(true);
});
test("bootstrap is idempotent across putSchema calls", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
@@ -501,253 +400,4 @@ describe("bootstrap meta-schema self-reference", () => {
const metaNode = store.get(metaHash) as CasNode;
expect(metaNode.type).toBe(metaHash);
});
// ── P2 combinators, conditionals, and leaf constraints ──────────────────
test("accepts schema with allOf", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
allOf: [
{ type: "object", properties: { name: { type: "string" } } },
{ required: ["name"] },
],
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { name: "Alice" });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, {});
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with if/then/else", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
properties: {
kind: { type: "string" },
value: {},
},
if: { properties: { kind: { const: "number" } } },
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable
then: { properties: { value: { type: "number" } } },
else: { properties: { value: { type: "string" } } },
});
expect(hash).toHaveLength(13);
});
test("accepts schema with patternProperties", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
patternProperties: {
"^x-": { type: "string" },
},
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { "x-custom": "hello" });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
});
test("accepts schema with prefixItems (tuple)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "array",
prefixItems: [{ type: "string" }, { type: "number" }],
});
expect(hash).toHaveLength(13);
});
test("accepts schema with multipleOf", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "number",
multipleOf: 5,
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, 15);
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, 7);
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with minProperties/maxProperties", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
minProperties: 1,
maxProperties: 3,
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { a: 1, b: 2 });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const empty = await store.put(hash, {});
expect(validate(store, store.get(empty) as CasNode)).toBe(false);
});
test("accepts schema with default value", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "string",
default: "hello",
});
expect(hash).toHaveLength(13);
});
test("rejects invalid P2 keyword types", async () => {
const store = createMemoryStore();
await expect(
putSchema(store, { allOf: "not-array" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { multipleOf: "five" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { patternProperties: [1, 2] } as never),
).rejects.toThrow();
});
test("collectRefs traverses allOf sub-schemas", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
allOf: [
{
type: "object",
properties: { ref: { type: "string", format: "cas_ref" } },
},
],
});
const targetHash = await store.put(innerSchema, "target");
const nodeHash = await store.put(schema, { ref: targetHash });
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
test("collectRefs traverses patternProperties", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
type: "object",
patternProperties: {
"^ref_": { type: "string", format: "cas_ref" },
},
});
const targetHash = await store.put(innerSchema, "hello");
const nodeHash = await store.put(schema, { ref_a: targetHash });
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
test("collectRefs traverses prefixItems", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
type: "array",
prefixItems: [{ type: "string", format: "cas_ref" }, { type: "number" }],
});
const targetHash = await store.put(innerSchema, "hello");
const nodeHash = await store.put(schema, [targetHash, 42]);
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
// ── P3 combinators, propertyNames, and metadata ──────────────────────────
test("accepts schema with not", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
not: { type: "string" },
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, 42);
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, "hello");
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with contains", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "array",
contains: { type: "number", minimum: 10 },
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, [1, 2, 15]);
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, [1, 2, 3]);
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with propertyNames", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
propertyNames: { pattern: "^[a-z]+$" },
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { foo: 1, bar: 2 });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, { Foo: 1 });
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with metadata keywords", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "string",
examples: ["hello", "world"],
readOnly: true,
deprecated: false,
$comment: "This is a test schema",
});
expect(hash).toHaveLength(13);
});
test("rejects invalid P3 keyword types", async () => {
const store = createMemoryStore();
await expect(
putSchema(store, { not: "not-object" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { examples: "not-array" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { readOnly: "yes" } as never),
).rejects.toThrow();
await expect(putSchema(store, { $comment: 42 } as never)).rejects.toThrow();
});
test("collectRefs traverses contains", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
type: "array",
contains: { type: "string", format: "cas_ref" },
});
const targetHash = await store.put(innerSchema, "hello");
const nodeHash = await store.put(schema, [targetHash, "not-a-ref"]);
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
});
+6 -183
View File
@@ -35,39 +35,6 @@ const ALLOWED_SCHEMA_KEYS = new Set([
"enum",
"const",
"description",
// P1 leaf constraints (no collectRefs impact)
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"minLength",
"maxLength",
"pattern",
"minItems",
"maxItems",
"uniqueItems",
// P2 combinators + conditionals (need collectRefs)
"allOf",
"if",
"then",
"else",
"patternProperties",
"prefixItems",
// P2 leaf constraints
"multipleOf",
"minProperties",
"maxProperties",
"default",
// P3 combinators (need collectRefs)
"not",
"contains",
"propertyNames",
// P3 metadata (no collectRefs impact)
"examples",
"readOnly",
"writeOnly",
"deprecated",
"$comment",
]);
const JSON_SCHEMA_TYPES = new Set([
@@ -159,83 +126,6 @@ function isValidSchema(value: unknown): boolean {
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
}
// P1 leaf constraints — type checks only
if ("minimum" in schema && typeof schema.minimum !== "number") return false;
if ("maximum" in schema && typeof schema.maximum !== "number") return false;
if (
"exclusiveMinimum" in schema &&
typeof schema.exclusiveMinimum !== "number"
)
return false;
if (
"exclusiveMaximum" in schema &&
typeof schema.exclusiveMaximum !== "number"
)
return false;
if ("minLength" in schema && typeof schema.minLength !== "number")
return false;
if ("maxLength" in schema && typeof schema.maxLength !== "number")
return false;
if ("pattern" in schema && typeof schema.pattern !== "string") return false;
if ("minItems" in schema && typeof schema.minItems !== "number") return false;
if ("maxItems" in schema && typeof schema.maxItems !== "number") return false;
if ("uniqueItems" in schema && typeof schema.uniqueItems !== "boolean")
return false;
// P2 combinators + conditionals — recursive sub-schema checks
if ("allOf" in schema) {
if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) return false;
for (const entry of schema.allOf) {
if (!isValidSchema(entry)) return false;
}
}
if ("if" in schema && !isValidSchema(schema.if)) return false;
if ("then" in schema && !isValidSchema(schema.then)) return false;
if ("else" in schema && !isValidSchema(schema.else)) return false;
if ("patternProperties" in schema) {
const pp = schema.patternProperties;
if (pp === null || typeof pp !== "object" || Array.isArray(pp))
return false;
for (const nested of Object.values(pp as Record<string, unknown>)) {
if (!isValidSchema(nested)) return false;
}
}
if ("prefixItems" in schema) {
if (!Array.isArray(schema.prefixItems) || schema.prefixItems.length === 0)
return false;
for (const entry of schema.prefixItems) {
if (!isValidSchema(entry)) return false;
}
}
// P2 leaf constraints — type checks only
if ("multipleOf" in schema && typeof schema.multipleOf !== "number")
return false;
if ("minProperties" in schema && typeof schema.minProperties !== "number")
return false;
if ("maxProperties" in schema && typeof schema.maxProperties !== "number")
return false;
// "default" accepts any value — no type check needed
// P3 combinators — recursive sub-schema checks
if ("not" in schema && !isValidSchema(schema.not)) return false;
if ("contains" in schema && !isValidSchema(schema.contains)) return false;
if ("propertyNames" in schema && !isValidSchema(schema.propertyNames))
return false;
// P3 metadata — type checks only
if ("examples" in schema && !Array.isArray(schema.examples)) return false;
if ("readOnly" in schema && typeof schema.readOnly !== "boolean")
return false;
if ("writeOnly" in schema && typeof schema.writeOnly !== "boolean")
return false;
if ("deprecated" in schema && typeof schema.deprecated !== "boolean")
return false;
if ("$comment" in schema && typeof schema.$comment !== "string") return false;
return true;
}
@@ -293,9 +183,8 @@ export function validate(store: Store, node: CasNode): boolean {
/**
* Recursively collect values of all properties whose schema has format: 'cas_ref'.
* Handles: direct format, anyOf/allOf (combinators), oneOf, if/then/else (conditionals),
* not, contains, items + prefixItems (arrays), properties (nested objects),
* additionalProperties (record refs), and patternProperties (regex-keyed refs).
* Handles: direct format, anyOf (nullable refs), items (array refs),
* properties (nested objects), and additionalProperties (record refs).
*/
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
const result: Hash[] = [];
@@ -314,55 +203,11 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
return result;
}
// P2: allOf — each sub-schema applies to the same value
if (Array.isArray(schema.allOf)) {
for (const sub of schema.allOf as JSONSchema[]) {
result.push(...collectRefs(sub, value));
if (schema.type === "array" && schema.items && Array.isArray(value)) {
const itemSchema = schema.items as JSONSchema;
for (const item of value as unknown[]) {
result.push(...collectRefs(itemSchema, item));
}
}
// P2: if/then/else — conditional sub-schemas apply to the same value
if (schema.if && typeof schema.if === "object") {
result.push(...collectRefs(schema.if as JSONSchema, value));
}
if (schema.then && typeof schema.then === "object") {
result.push(...collectRefs(schema.then as JSONSchema, value));
}
if (schema.else && typeof schema.else === "object") {
result.push(...collectRefs(schema.else as JSONSchema, value));
}
if (schema.type === "array" && Array.isArray(value)) {
// P2: prefixItems — tuple validation, each item has its own schema
if (Array.isArray(schema.prefixItems)) {
const tupleSchemas = schema.prefixItems as JSONSchema[];
const arr = value as unknown[];
for (let i = 0; i < tupleSchemas.length && i < arr.length; i++) {
const ts = tupleSchemas[i];
if (ts) result.push(...collectRefs(ts, arr[i]));
}
}
if (schema.items) {
const itemSchema = schema.items as JSONSchema;
// When prefixItems exists, items applies only to remaining elements
const startIdx = Array.isArray(schema.prefixItems)
? (schema.prefixItems as unknown[]).length
: 0;
const arr = value as unknown[];
for (let i = startIdx; i < arr.length; i++) {
result.push(...collectRefs(itemSchema, arr[i]));
}
}
// P3: contains — sub-schema for array items
if (schema.contains && typeof schema.contains === "object") {
const containsSchema = schema.contains as JSONSchema;
for (const item of value as unknown[]) {
result.push(...collectRefs(containsSchema, item));
}
}
return result;
}
@@ -385,28 +230,6 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
result.push(...collectRefs(addlSchema, val));
}
}
// P2: patternProperties — regex-keyed property schemas
if (
schema.patternProperties &&
typeof schema.patternProperties === "object"
) {
const pp = schema.patternProperties as Record<string, JSONSchema>;
const obj = value as Record<string, unknown>;
for (const [pat, subSchema] of Object.entries(pp)) {
const re = new RegExp(pat);
for (const [key, val] of Object.entries(obj)) {
if (re.test(key)) {
result.push(...collectRefs(subSchema, val));
}
}
}
}
}
// P3: not — sub-schema applies to the same value
if (schema.not && typeof schema.not === "object") {
result.push(...collectRefs(schema.not as JSONSchema, value));
}
return result;
-89
View File
@@ -1,89 +0,0 @@
import { describe, expect, test } from "bun:test";
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
import { createMemoryStore } from "./store.js";
describe("createMemoryStore – meta and schema indexes", () => {
test("B1. listMeta and listSchemas are empty on a fresh store", () => {
const store = createMemoryStore();
expect(store.listMeta()).toEqual([]);
expect(store.listSchemas()).toEqual([]);
});
test("B2. self-referencing put adds hash to metaSet and listSchemas", async () => {
const store = createMemoryStore();
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
expect(store.listMeta()).toContain(hash);
expect(store.listSchemas()).toContain(hash);
});
test("B3. regular put does not add hash to metaSet", async () => {
const store = createMemoryStore();
const metaHash = await store[BOOTSTRAP_STORE]({ type: "object" });
const schemaHash = await store.put(metaHash, { type: "string" });
expect(store.listMeta()).not.toContain(schemaHash);
expect(store.listMeta()).toContain(metaHash);
});
test("B4. schema typed by meta-schema appears in listSchemas", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s = await store.put(m, { type: "string" });
const schemas = store.listSchemas();
expect(schemas).toContain(m);
expect(schemas).toContain(s);
const meta = store.listMeta();
expect(meta).toContain(m);
expect(meta).not.toContain(s);
});
test("B5. multiple meta-schemas (versioning)", async () => {
const store = createMemoryStore();
const m1 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v1" });
const m2 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v2" });
const s1 = await store.put(m1, { type: "string" });
const s2 = await store.put(m2, { type: "number" });
const meta = store.listMeta();
expect(meta).toContain(m1);
expect(meta).toContain(m2);
expect(meta).toHaveLength(2);
const schemas = store.listSchemas();
expect(schemas).toContain(m1);
expect(schemas).toContain(m2);
expect(schemas).toContain(s1);
expect(schemas).toContain(s2);
});
test("B6. idempotent self-referencing put does not duplicate", async () => {
const store = createMemoryStore();
const payload = { type: "object", title: "dup" };
const h1 = await store[BOOTSTRAP_STORE](payload);
const h2 = await store[BOOTSTRAP_STORE](payload);
expect(h1).toBe(h2);
const meta = store.listMeta();
const occurrences = meta.filter((h) => h === h1).length;
expect(occurrences).toBe(1);
});
test("B7. delete removes hash from metaSet and listSchemas", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s = await store.put(m, { type: "string" });
expect(store.listMeta()).toContain(m);
expect(store.listSchemas()).toContain(s);
store.delete(m);
expect(store.listMeta()).not.toContain(m);
// schemas typed by deleted meta no longer surface
expect(store.listSchemas()).not.toContain(s);
expect(store.listSchemas()).not.toContain(m);
});
});
-19
View File
@@ -8,7 +8,6 @@ import type { CasNode, Hash } from "./types.js";
export function createMemoryStore(): BootstrapCapableStore {
const data = new Map<Hash, CasNode>();
const byType = new Map<Hash, Set<Hash>>();
const metaSet = new Set<Hash>();
function indexHash(type: Hash, hash: Hash): void {
let set = byType.get(type);
@@ -25,7 +24,6 @@ export function createMemoryStore(): BootstrapCapableStore {
data.set(hash, { type: hash, payload, timestamp: Date.now() });
indexHash(hash, hash);
}
metaSet.add(hash);
return hash;
}
@@ -58,22 +56,6 @@ export function createMemoryStore(): BootstrapCapableStore {
return Array.from(data.keys());
},
listMeta(): Hash[] {
return Array.from(metaSet);
},
listSchemas(): Hash[] {
const result = new Set<Hash>();
for (const meta of metaSet) {
result.add(meta);
const set = byType.get(meta);
if (set) {
for (const h of set) result.add(h);
}
}
return Array.from(result);
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
@@ -86,7 +68,6 @@ export function createMemoryStore(): BootstrapCapableStore {
byType.delete(node.type);
}
}
metaSet.delete(hash);
}
},
-2
View File
@@ -25,7 +25,5 @@ export type Store = {
has(hash: Hash): boolean;
listByType(typeHash: Hash): Hash[];
listAll(): Hash[];
listMeta(): Hash[];
listSchemas(): Hash[];
delete(hash: Hash): void;
};
@@ -1,85 +0,0 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { createMemoryStore } from "./store.js";
import { wrapEnvelope } from "./wrap-envelope.js";
describe("wrapEnvelope", () => {
test("resolves @output/put alias and returns envelope", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@output/put", "AAAAAAAAAAAAA");
expect(envelope.type).toBe(aliases["@output/put"]);
expect(envelope.value).toBe("AAAAAAAAAAAAA");
});
test("resolves @output/has alias with boolean value", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@output/has", true);
expect(envelope.type).toBe(aliases["@output/has"]);
expect(envelope.value).toBe(true);
});
test("resolves @output/gc alias with object value", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const gcStats = { total: 100, reachable: 80, collected: 20, scanned: 5 };
const envelope = await wrapEnvelope(store, "@output/gc", gcStats);
expect(envelope.type).toBe(aliases["@output/gc"]);
expect(envelope.value).toEqual(gcStats);
});
test("resolves primitive alias @string", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@string", "hello");
expect(envelope.type).toBe(aliases["@string"]);
expect(envelope.value).toBe("hello");
});
test("throws for unknown alias", async () => {
const store = createMemoryStore();
await bootstrap(store);
await expect(
wrapEnvelope(store, "@output/nonexistent", "value"),
).rejects.toThrow("Unknown schema alias: @output/nonexistent");
});
test("is idempotent — same alias returns same type hash", async () => {
const store = createMemoryStore();
const first = await wrapEnvelope(store, "@output/verify", "ok");
const second = await wrapEnvelope(store, "@output/verify", "corrupted");
expect(first.type).toBe(second.type);
expect(first.value).toBe("ok");
expect(second.value).toBe("corrupted");
});
test("preserves complex object values without mutation", async () => {
const store = createMemoryStore();
await bootstrap(store);
const original = {
name: "test",
schema: "AAAAAAAAAAAAA",
value: "BBBBBBBBBBBBB",
created: 1000,
updated: 2000,
tags: { env: "prod" },
labels: ["stable"],
};
const envelope = await wrapEnvelope(store, "@output/var-set", original);
expect(envelope.value).toEqual(original);
});
});
-19
View File
@@ -1,19 +0,0 @@
import { bootstrap } from "./bootstrap.js";
import type { Hash, Store } from "./types.js";
/**
* Resolve a schema alias (e.g. "@output/put") to its hash via bootstrap,
* then return a typed envelope ready for store.put() or direct rendering.
*/
export async function wrapEnvelope(
store: Store,
schemaAlias: string,
value: unknown,
): Promise<{ type: Hash; value: unknown }> {
const aliases = await bootstrap(store);
const typeHash = aliases[schemaAlias];
if (typeHash === undefined) {
throw new Error(`Unknown schema alias: ${schemaAlias}`);
}
return { type: typeHash, value };
}
@@ -72,28 +72,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
expect(properties).not.toHaveProperty("$ref");
expect(properties).not.toHaveProperty("$id");
expect(properties).not.toHaveProperty("$defs");
// P2 keywords should be present
expect(properties).toHaveProperty("allOf");
expect(properties).toHaveProperty("if");
expect(properties).toHaveProperty("then");
expect(properties).toHaveProperty("else");
expect(properties).toHaveProperty("patternProperties");
expect(properties).toHaveProperty("prefixItems");
expect(properties).toHaveProperty("multipleOf");
expect(properties).toHaveProperty("minProperties");
expect(properties).toHaveProperty("maxProperties");
expect(properties).toHaveProperty("default");
// P3 keywords should be present
expect(properties).toHaveProperty("not");
expect(properties).toHaveProperty("contains");
expect(properties).toHaveProperty("propertyNames");
expect(properties).toHaveProperty("examples");
expect(properties).toHaveProperty("readOnly");
expect(properties).toHaveProperty("writeOnly");
expect(properties).toHaveProperty("deprecated");
expect(properties).toHaveProperty("$comment");
expect(properties).not.toHaveProperty("allOf");
expect(properties).not.toHaveProperty("not");
});
test("1.5: Meta-schema node type equals its own hash", async () => {
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
# Prevent publishing packages that still reference workspace:* dependencies
if grep -q '"workspace:' package.json 2>/dev/null; then
echo "❌ Found workspace:* dependencies in package.json — cannot publish directly."
echo " Use 'changeset publish' which resolves workspace protocol automatically."
grep '"workspace:' package.json
exit 1
fi