Compare commits

...

12 Commits

Author SHA1 Message Date
xiaoju 9090456ed2 feat: wrap template commands with envelope, update docs (Phase 4)
Fixes #72
2026-05-31 16:00:21 +00:00
xiaoju 15ffff1fd4 Merge pull request 'feat: wrap refs/walk/gc/var with envelope (Phase 3)' (#80) from fix/71-refs-walk-var-envelope into main 2026-05-31 15:41:08 +00:00
xiaoju d2b9dee4b9 feat: wrap refs/walk/gc/var with {type,value} envelope (Phase 3)
Fixes #71
2026-05-31 15:41:02 +00:00
xiaoju 547c45829f Merge pull request 'feat: wrap simple commands with envelope (Phase 2)' (#79) from fix/70-simple-envelope into main 2026-05-31 15:29:54 +00:00
xiaoju d328fbe6e4 feat: wrap put/get/has/hash/verify/list with {type,value} envelope (Phase 2)
Fixes #70
2026-05-31 15:29:49 +00:00
xiaoju 11bb6b3e32 Merge pull request 'feat: register 18 @output/* schemas, default templates, wrapEnvelope (Phase 1c)' (#78) from fix/75-output-schemas into main 2026-05-31 15:14:49 +00:00
xiaoju 0dca8a5521 feat: register 18 @output/* schemas, default templates, wrapEnvelope (Phase 1c)
Fixes #75
2026-05-31 15:14:28 +00:00
xiaoju 5aad956c83 Merge pull request 'feat: remove cat/schema commands, add list --type, enhance verify (Phase 1b)' (#77) from fix/74-remove-cat-schema into main 2026-05-31 14:52:36 +00:00
xiaoju 038c901f4b feat: remove cat/schema commands, add list --type, enhance verify (Phase 1b)
Fixes #74
2026-05-31 14:41:38 +00:00
xiaoju f4cf92e128 Merge pull request 'feat: auto-bootstrap CAS store on open (Phase 1a)' (#76) from fix/73-auto-bootstrap into main
feat: auto-bootstrap CAS store on open (Phase 1a)

Fixes #73
2026-05-31 13:40:43 +00:00
xiaoju c8bf38cb81 feat: auto-bootstrap CAS store on open (Phase 1a)
Changes:
1. openStore() now async, auto-creates directory and bootstraps
2. Removed shouldCreate parameter and validateStoreExists()
3. All openStore() calls now awaited
4. Deleted init and bootstrap commands
5. Removed Issue #55 store validation tests (superseded by auto-bootstrap)
6. Enhanced error handling in openStore() for permission/path issues
7. Updated e2e snapshots after fixing missed await in cmdRender

Bootstrap is idempotent. Core tests using createMemoryStore() unaffected.

Fixes #73

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 13:34:43 +00:00
xingyue b93d7b229a Merge pull request 'feat(cli): convert e2e-check scenarios to snapshot fixture tests' (#68) from fix/66-e2e-snapshot-tests into main 2026-05-31 11:43:32 +00:00
18 changed files with 1494 additions and 1065 deletions
+46 -16
View File
@@ -88,30 +88,60 @@ Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/
## CLI Reference
Binary: `json-cas` (from `@uncaged/cli-json-cas`). Default store: `~/.uncaged/json-cas`.
Binary: `json-cas` (also aliased `ucas`, from `@uncaged/cli-json-cas`). 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 }
```
```
Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
init Create store dir and write bootstrap seed
bootstrap Write meta-schema seed, print hash
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
schema validate <hash> Validate node against its schema
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
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)
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)
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)
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
+80 -31
View File
@@ -4,7 +4,9 @@ CLI tool for json-cas stores.
## Overview
`@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.
`@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.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
@@ -37,48 +39,95 @@ Usage: json-cas [--store <path>] [--json] <command> [args]
| Flag | Description |
|------|-------------|
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
| `--json` | Compact JSON output for commands that print JSON |
| `--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" } }
```
### Commands
| 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 |
| 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` |
### Examples
```bash
# Initialize default store at ~/.uncaged/json-cas
json-cas init
# 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)
# 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 put 0123456789ABC ./payloads/item.json
# → { "type": "...", "value": "<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
@@ -1,72 +1,46 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 1: CAS Core 1.3 schema get returns schema JSON (snapshot) 1`] = `
"{
"type": "object",
"required": [
"name"
],
"properties": {
"age": {
"type": "number"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
}"
`;
exports[`Phase 1: CAS Core 1.4 schema list shows registered schemas 1`] = `
"8J5E9WCD54ZJZ (unnamed)
89ZQWMV9PTC7B (unnamed)
86QB0Q5VA7797 (unnamed)
AKEX4KYV98MGT (unnamed)
AGSJVKM01WNKZ (unnamed)
7XX5H51CVD9H0 (unnamed)"
`;
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"payload": {
"age": 30,
"name": "Alice",
"type": "ASE7K6A0HG8W9",
"value": {
"payload": {
"age": 30,
"name": "Alice",
},
"type": "7XX5H51CVD9H0",
},
"type": "7XX5H51CVD9H0",
}
`;
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `"ok"`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `""`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `"ERARPP19YJT05"`;
exports[`Phase 1: CAS Core 1.13 cat returns full node (snapshot) 1`] = `
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
{
"payload": {
"age": 30,
"name": "Alice",
},
"type": "7XX5H51CVD9H0",
"type": "8E2M8H30BHXS8",
"value": "ok",
}
`;
exports[`Phase 1: CAS Core 1.14 cat --payload returns only payload (snapshot) 1`] = `
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
"{
"age": 30,
"name": "Alice"
"type": "4N5REDA48XYJP",
"value": []
}"
`;
exports[`Phase 2: Schema Validation 2.2 schema validate on valid node returns valid 1`] = `"valid"`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
"{
"type": "7124NEATTWYYY",
"value": [
"ERARPP19YJT05"
]
}"
`;
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"type": "AYHQD2YA9G667",
"value": {
"labels": [],
"name": "myapp/config",
@@ -79,7 +53,7 @@ exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"type": "BVW2SAJ8606EZ",
"value": {
"labels": [],
"name": "myapp/config",
@@ -92,7 +66,7 @@ exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
{
"type": "E1D32N3GT69Q8",
"type": "3BY1S4RKNMR0P",
"value": [
{
"labels": [],
@@ -107,7 +81,7 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
{
"type": "E1D32N3GT69Q8",
"type": "3BY1S4RKNMR0P",
"value": [
{
"labels": [],
@@ -122,7 +96,7 @@ exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"type": "AYHQD2YA9G667",
"value": {
"labels": [],
"name": "myapp/config",
@@ -135,7 +109,7 @@ exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
{
"type": "E1D32N3GT69Q8",
"type": "BKMJ3DJHTS6VB",
"value": {
"labels": [
"important",
@@ -152,7 +126,7 @@ exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "E1D32N3GT69Q8",
"type": "3BY1S4RKNMR0P",
"value": [
{
"labels": [
@@ -171,7 +145,7 @@ exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
{
"type": "E1D32N3GT69Q8",
"type": "3BY1S4RKNMR0P",
"value": [
{
"labels": [
@@ -190,7 +164,7 @@ exports[`Phase 3: Variable System 3.8 var list --tag important filters by label
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
{
"type": "E1D32N3GT69Q8",
"type": "BKMJ3DJHTS6VB",
"value": {
"labels": [],
"name": "myapp/config",
@@ -205,7 +179,7 @@ exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"type": "ASWN8JEGAG7AP",
"value": [
{
"labels": [],
@@ -223,27 +197,41 @@ exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=7XX5H51CVD9H0"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
"{
"schemaHash": "7XX5H51CVD9H0",
"contentHash": "FC8WACA792B6F"
}"
{
"type": "9YJZ09DDAYAWR",
"value": {
"contentHash": "FC8WACA792B6F",
"schemaHash": "7XX5H51CVD9H0",
},
}
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `"Name: {{ payload.name }}, Age: {{ payload.age }}"`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
{
"type": "FJG23DR9456WA",
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
}
`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
"[
{
"schemaHash": "7XX5H51CVD9H0",
"preview": "Name: {{ payload.name }}, Age: {{ payload.age }}"
}
]"
{
"type": "3JB2JHXHZG2Z1",
"value": [
{
"contentHash": "FC8WACA792B6F",
"schemaHash": "7XX5H51CVD9H0",
},
],
}
`;
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
"{
"deleted": true
}"
{
"type": "0PYGQE16XPM70",
"value": {
"deleted": true,
},
}
`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 7XX5H51CVD9H0"`;
@@ -258,38 +246,34 @@ exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: json-c
exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`;
exports[`Phase 7: Edge Cases 7.5 schema put invalid schema errors 1`] = `"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema"`;
exports[`Phase 7: Edge Cases 7.6 no subcommand shows help text 1`] = `
exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = `
"Usage: json-cas [--store <path>] [--json] <command> [args]
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:
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
put <type-hash> <file.json> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
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)
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)
+95 -464
View File
@@ -1,13 +1,41 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { bootstrap } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { JSONSchema } from "@uncaged/json-cas";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore, openStore as openFsStore } from "@uncaged/json-cas-fs";
const pkgPath = resolve(import.meta.dir, "../package.json");
const entrypoint = resolve(import.meta.dir, "index.ts");
/** 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;
}
/**
* Register a schema directly via the library (CLI schema put was removed).
* Returns the type hash.
*/
async function putSchemaFile(
storePath: string,
schemaFilePath: string,
): Promise<string> {
const store = await openFsStore(storePath);
const schema = JSON.parse(
readFileSync(schemaFilePath, "utf-8"),
) as JSONSchema;
const hash = await putSchema(store, schema);
return hash;
}
async function runCli(
args: string[],
storePath?: string,
@@ -130,86 +158,6 @@ async function runCliAlias(...args: string[]): Promise<{
};
}
describe("@ Alias Resolution - schema get", () => {
test("ucas schema get @string should work", async () => {
await runCliAlias("init"); // Initialize store
const { stdout, stderr, exitCode } = await runCliAlias(
"schema",
"get",
"@string",
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const schema = JSON.parse(stdout);
expect(schema).toEqual({ type: "string" });
});
test("ucas schema get @number should work", async () => {
await runCliAlias("init");
const { stdout, exitCode } = await runCliAlias("schema", "get", "@number");
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema).toEqual({ type: "number" });
});
test("ucas schema get @object should work", async () => {
await runCliAlias("init");
const { stdout, exitCode } = await runCliAlias("schema", "get", "@object");
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema).toEqual({ type: "object" });
});
test("ucas schema get @array should work", async () => {
await runCliAlias("init");
const { stdout, exitCode } = await runCliAlias("schema", "get", "@array");
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema).toEqual({ type: "array" });
});
test("ucas schema get @bool should work", async () => {
await runCliAlias("init");
const { stdout, exitCode } = await runCliAlias("schema", "get", "@bool");
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema).toEqual({ type: "boolean" });
});
test("ucas schema get @schema should work", async () => {
await runCliAlias("init");
const { stdout, exitCode } = await runCliAlias("schema", "get", "@schema");
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema).toHaveProperty("type", "object");
expect(schema).toHaveProperty(
"description",
"json-cas JSON Schema meta-schema",
);
});
test("ucas schema get @invalid should fail gracefully", async () => {
await runCliAlias("init");
const { stderr, exitCode } = await runCliAlias("schema", "get", "@invalid");
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Schema not found");
});
});
describe("@ Alias Resolution - put", () => {
test("ucas put @string <file> should resolve alias", async () => {
await runCliAlias("init");
@@ -225,8 +173,8 @@ describe("@ Alias Resolution - put", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
// Should output a valid hash (13 chars)
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// 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 () => {
@@ -242,7 +190,7 @@ describe("@ Alias Resolution - put", () => {
);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @object <file> should resolve alias", async () => {
@@ -258,7 +206,7 @@ describe("@ Alias Resolution - put", () => {
);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @invalid <file> should fail", async () => {
@@ -293,7 +241,7 @@ describe("@ Alias Resolution - hash", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
@@ -357,10 +305,7 @@ describe("Issue #50: Schema Validation in put", () => {
additionalProperties: false,
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Create valid payload
const payloadFile = join(tmpStore, "payload.json");
@@ -373,10 +318,10 @@ describe("Issue #50: Schema Validation in put", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Verify node was stored
const hash = stdout.trim();
const hash = envValue(stdout) as string;
const { exitCode: hasExitCode } = await runCli(["has", hash], tmpStore);
expect(hasExitCode).toBe(0);
} finally {
@@ -403,10 +348,7 @@ describe("Issue #50: Schema Validation in put", () => {
additionalProperties: false,
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with only required properties
const payloadFile = join(tmpStore, "payload.json");
@@ -418,7 +360,7 @@ describe("Issue #50: Schema Validation in put", () => {
);
expect(exitCode).toBe(0);
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -448,10 +390,7 @@ describe("Issue #50: Schema Validation in put", () => {
additionalProperties: false,
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with nested structure
const payloadFile = join(tmpStore, "payload.json");
@@ -469,7 +408,7 @@ describe("Issue #50: Schema Validation in put", () => {
);
expect(exitCode).toBe(0);
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -489,7 +428,7 @@ describe("Issue #50: Schema Validation in put", () => {
);
expect(exitCode).toBe(0);
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -513,10 +452,7 @@ describe("Issue #50: Schema Validation in put", () => {
additionalProperties: false,
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with name as number
const payloadFile = join(tmpStore, "payload.json");
@@ -538,7 +474,7 @@ describe("Issue #50: Schema Validation in put", () => {
["has", "0000000000000"],
tmpStore,
);
expect(hasOutput.trim()).toBe("false");
expect(envValue(hasOutput)).toBe(false);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -560,10 +496,7 @@ describe("Issue #50: Schema Validation in put", () => {
additionalProperties: false,
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Empty payload
const payloadFile = join(tmpStore, "payload.json");
@@ -596,10 +529,7 @@ describe("Issue #50: Schema Validation in put", () => {
additionalProperties: false,
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with extra property
const payloadFile = join(tmpStore, "payload.json");
@@ -634,10 +564,7 @@ describe("Issue #50: Schema Validation in put", () => {
items: { type: "string" },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload is an object
const payloadFile = join(tmpStore, "payload.json");
@@ -676,10 +603,7 @@ describe("Issue #50: Schema Validation in put", () => {
},
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with wrong nested type
const payloadFile = join(tmpStore, "payload.json");
@@ -760,10 +684,7 @@ describe("Issue #50: Schema Validation in put", () => {
additionalProperties: false,
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Invalid payload
const payloadFile = join(tmpStore, "payload.json");
@@ -776,7 +697,7 @@ describe("Issue #50: Schema Validation in put", () => {
);
expect(exitCode).toBe(0);
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -798,10 +719,7 @@ describe("Issue #50: Schema Validation in put", () => {
},
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Valid cas_ref
const validFile = join(tmpStore, "valid.json");
@@ -843,11 +761,8 @@ describe("Issue #50: Schema Validation in put", () => {
}),
);
const { exitCode } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
expect(exitCode).toBe(0);
const hash = await putSchemaFile(tmpStore, schemaFile);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -869,10 +784,7 @@ describe("Issue #50: Schema Validation in put", () => {
required: ["name"],
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
@@ -903,10 +815,7 @@ describe("Issue #50: Schema Validation in put", () => {
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
@@ -941,41 +850,35 @@ describe("Suite 6: CLI Integration with Templates", () => {
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Create node
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice" }));
const { stdout: nodeHash } = await runCli(
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: tmplHash } = await runCli(
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.trim(),
],
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
// Render with template
const { stdout: output, exitCode } = await runCli(
["render", nodeHash.trim()],
["render", nodeHash],
tmpStore,
);
@@ -1005,29 +908,28 @@ describe("Suite 6: CLI Integration with Templates", () => {
},
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Create child node
const childFile = join(tmpStore, "child.json");
writeFileSync(childFile, JSON.stringify({ value: "child", child: null }));
const { stdout: childHash } = await runCli(
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.trim() }),
JSON.stringify({ value: "parent", child: childHash }),
);
const { stdout: parentHash } = await runCli(
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");
@@ -1035,25 +937,21 @@ describe("Suite 6: CLI Integration with Templates", () => {
templateFile,
JSON.stringify("{{ payload.value }}(res={{ resolution }})"),
);
const { stdout: tmplHash } = await runCli(
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.trim(),
],
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
// Render with custom decay
const { stdout: output, exitCode } = await runCli(
["render", parentHash.trim(), "--decay", "0.7"],
["render", parentHash, "--decay", "0.7"],
tmpStore,
);
@@ -1077,17 +975,15 @@ describe("Suite 6: CLI Integration with Templates", () => {
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Bob" }));
const { stdout: nodeHash } = await runCli(
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");
@@ -1095,25 +991,21 @@ describe("Suite 6: CLI Integration with Templates", () => {
templateFile,
JSON.stringify("Greetings {{ payload.name }}!"),
);
const { stdout: tmplHash } = await runCli(
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.trim(),
],
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
const { stdout: output, exitCode } = await runCli(
[
"render",
nodeHash.trim(),
nodeHash,
"--resolution",
"0.8",
"--decay",
@@ -1144,21 +1036,19 @@ describe("Suite 6: CLI Integration with Templates", () => {
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" }));
const { stdout: nodeHash } = await runCli(
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.trim()],
["render", nodeHash],
tmpStore,
);
@@ -1183,20 +1073,18 @@ describe("Suite 6: CLI Integration with Templates", () => {
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Test" }));
const { stdout: nodeHash } = await runCli(
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
const { exitCode, stderr } = await runCli(
["render", nodeHash.trim(), "--decay", "1.5"],
["render", nodeHash, "--decay", "1.5"],
tmpStore,
);
@@ -1237,14 +1125,15 @@ describe("Suite 6: CLI Integration with Templates", () => {
// Create and store a simple string node
const nodeFile = join(tmpStore, "test.json");
writeFileSync(nodeFile, JSON.stringify("hello world"));
const { stdout: nodeHash } = await runCli(
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.trim()],
["render", nodeHash],
tmpStore,
);
@@ -1309,264 +1198,6 @@ describe("Suite 6: CLI Integration with Templates", () => {
});
});
// ---- schema put - invalid schema error handling ----
describe("schema put - invalid schema error handling", () => {
test("invalid schema - unknown type value shows clean error", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "invalid-schema.json");
writeFileSync(schemaFile, JSON.stringify({ type: "invalid" }));
const { exitCode, stderr, stdout } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Invalid schema");
expect(stderr).not.toContain("at ");
expect(stdout).toBe("");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("invalid schema - unknown key shows clean error", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "invalid-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({ type: "string", unknownKey: true }),
);
const { exitCode, stderr, stdout } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Invalid schema");
expect(stderr).not.toContain("at ");
expect(stdout).toBe("");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("invalid schema - invalid nested schema shows clean error", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "invalid-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "invalid" },
},
}),
);
const { exitCode, stderr, stdout } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Invalid schema");
expect(stderr).not.toContain("at ");
expect(stdout).toBe("");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("invalid schema - non-object root shows clean error", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "invalid-schema.json");
writeFileSync(schemaFile, JSON.stringify(["type", "string"]));
const { exitCode, stderr, stdout } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Invalid schema");
expect(stderr).not.toContain("at ");
expect(stdout).toBe("");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("valid schema still works (regression)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "valid-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
}),
);
const { exitCode, stderr, stdout } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
describe("Store validation - non-existent paths (Issue #55)", () => {
test("E2E-55-1: get with non-existent store reports store not found", async () => {
const fakePath = "/nonexistent/path/to/store";
const { exitCode, stderr } = await runCli(
["get", "AAAAAAAAAAAAA"],
fakePath,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
expect(stderr).toContain(fakePath);
expect(stderr).not.toContain("Node not found");
});
test("E2E-55-2: has with non-existent store reports store not found", async () => {
const fakePath = `/tmp/nonexistent-store-${Date.now()}`;
const { exitCode, stderr } = await runCli(
["has", "BBBBBBBBBBBBB"],
fakePath,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-3: verify with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["verify", "CCCCCCCCCCCCC"],
"/does/not/exist",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-4: schema get with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["schema", "get", "@string"],
"/fake/path",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-5: schema list with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["schema", "list"],
"/nonexistent/store",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-6: walk with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["walk", "DDDDDDDDDDDDD"],
"/tmp/missing-store",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-7: cat with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["cat", "EEEEEEEEEEEEE"],
"/no/such/dir",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-8: refs with non-existent store reports store not found", async () => {
const { exitCode, stderr } = await runCli(
["refs", "FFFFFFFFFFFFF"],
"/nonexistent",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
});
test("E2E-55-9: existing store works normally (regression)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const payloadFile = join(tmpStore, "test.json");
writeFileSync(payloadFile, JSON.stringify("test"));
const { stdout: hash } = await runCli(
["put", "@string", payloadFile],
tmpStore,
);
const { exitCode, stdout, stderr } = await runCli(
["get", hash.trim()],
tmpStore,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const node = JSON.parse(stdout);
expect(node).toHaveProperty("type");
expect(node).toHaveProperty("payload", "test");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("E2E-55-10: init creates directory (should not validate)", async () => {
const newStore = join(tmpdir(), `new-store-${Date.now()}`);
try {
const { exitCode, stdout, stderr } = await runCli(["init"], newStore);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const { existsSync } = await import("node:fs");
expect(existsSync(newStore)).toBe(true);
} finally {
rmSync(newStore, { recursive: true, force: true });
}
});
});
// Store validation tests removed - with auto-bootstrap in Phase 1a,
// stores are automatically created and bootstrapped when opened.
// Issue #55 validation is no longer applicable.
+158 -83
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
@@ -54,16 +54,32 @@ function stripVolatile(json: string): unknown {
return strip(JSON.parse(json));
}
/** Extract the `value` field from a { type, value } envelope JSON string. */
function envValue(json: string): unknown {
return (JSON.parse(json) as { value: unknown }).value;
}
/** Run a CLI command feeding `stdin` to its standard input. */
async function runCliWithStdin(
args: string[],
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
// ---- Phase 1: CAS Core ----
describe("Phase 1: CAS Core", () => {
test("1.1 bootstrap returns 13-char Base32 hash", async () => {
const { stdout, exitCode } = await runCli(["init"]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("1.2 schema put returns type hash", async () => {
test("1.1 init + put with @object bootstraps store", async () => {
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
@@ -77,23 +93,16 @@ describe("Phase 1: CAS Core", () => {
additionalProperties: false,
}),
);
const { stdout, exitCode } = await runCli(["schema", "put", schemaFile]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
typeHash = stdout;
});
test("1.3 schema get returns schema JSON (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["schema", "get", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.4 schema list shows registered schemas", async () => {
const { stdout, exitCode } = await runCli(["schema", "list"]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
expect(stdout).toContain(typeHash);
// Use putSchema via the library to register schema, since CLI schema put is removed
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
const hash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
typeHash = hash;
expect(typeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("1.5 put returns node hash", async () => {
@@ -101,8 +110,8 @@ describe("Phase 1: CAS Core", () => {
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout, exitCode } = await runCli(["put", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
nodeHash = stdout;
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
nodeHash = envValue(stdout) as string;
});
test("1.6 get returns node JSON (snapshot)", async () => {
@@ -114,19 +123,19 @@ describe("Phase 1: CAS Core", () => {
test("1.7 has returns true for existing node", async () => {
const { stdout, exitCode } = await runCli(["has", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("true");
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(stdout).toBe("false");
expect(envValue(stdout)).toBe(false);
});
test("1.9 verify returns ok for valid node", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.10 refs lists direct references (snapshot)", async () => {
@@ -145,22 +154,13 @@ describe("Phase 1: CAS Core", () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stdout, exitCode } = await runCli(["hash", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(stdout).toBe(nodeHash);
expect(envValue(stdout)).toBe(nodeHash);
});
test("1.13 cat returns full node (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["cat", nodeHash]);
test("1.13 list --type returns nodes of that type", async () => {
const { stdout, exitCode } = await runCli(["list", "--type", typeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.14 cat --payload returns only payload (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["cat", nodeHash, "--payload"]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
const parsed = JSON.parse(stdout) as Record<string, unknown>;
expect(parsed).not.toHaveProperty("type");
expect(parsed).toHaveProperty("name", "Alice");
expect(envValue(stdout)).toContain(nodeHash);
});
});
@@ -182,10 +182,10 @@ describe("Phase 2: Schema Validation", () => {
// Do NOT snapshot stderr — it embeds a machine-specific tmp path
});
test("2.2 schema validate on valid node returns valid", async () => {
const { stdout, exitCode } = await runCli(["schema", "validate", nodeHash]);
test("2.2 verify on valid node returns ok (hash + schema)", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
expect(envValue(stdout)).toBe("ok");
});
test("2.3 put against non-existent schema hash fails", async () => {
@@ -244,12 +244,13 @@ describe("Phase 3: Variable System", () => {
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: node2Hash } = await runCli(["put", typeHash, node2File]);
const { stdout: node2Out } = await runCli(["put", typeHash, node2File]);
const node2Hash = envValue(node2Out) as string;
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
node2Hash.trim(),
node2Hash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
@@ -352,27 +353,29 @@ describe("Phase 4: Template System", () => {
tmplFile,
]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
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(stdout).toBe("Name: {{ payload.name }}, Age: {{ payload.age }}");
expect(stdout).toMatchSnapshot();
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(stdout).toMatchSnapshot();
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(stdout).toMatchSnapshot();
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.5 template get deleted template returns not found", async () => {
@@ -427,7 +430,7 @@ describe("Phase 6: GC", () => {
const gcNodeFile = join(tmpStore, "gc-node.json");
writeFileSync(gcNodeFile, JSON.stringify({ name: "GcAlice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, gcNodeFile]);
gcNodeHash = stdout.trim();
gcNodeHash = envValue(stdout) as string;
// Set a var referencing this node so it survives GC during Phase 6
await runCli(["var", "set", "gc-test/ref", gcNodeHash]);
});
@@ -436,7 +439,7 @@ describe("Phase 6: GC", () => {
const { exitCode, stdout } = await runCli(["gc"]);
expect(exitCode).toBe(0);
// Assert structural shape only — exact counts depend on phase history
const result = JSON.parse(stdout) as Record<string, unknown>;
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");
@@ -450,25 +453,21 @@ describe("Phase 6: GC", () => {
const { exitCode } = await runCli(["gc"]);
expect(exitCode).toBe(0);
const { stdout } = await runCli(["has", gcNodeHash]);
expect(stdout).toBe("true");
expect(envValue(stdout)).toBe(true);
});
test("6.3 gc reclaims orphan node", async () => {
const orphanFile = join(tmpStore, "orphan.json");
writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 }));
const { stdout: orphanHashRaw } = await runCli([
"put",
typeHash,
orphanFile,
]);
const orphanHash = orphanHashRaw.trim();
const { stdout: orphanOut } = await runCli(["put", typeHash, orphanFile]);
const orphanHash = envValue(orphanOut) as string;
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
expect(beforeGc).toBe("true");
expect(envValue(beforeGc)).toBe(true);
await runCli(["gc"]);
const { stdout: afterGc } = await runCli(["has", orphanHash]);
expect(afterGc).toBe("false");
expect(envValue(afterGc)).toBe(false);
});
});
@@ -510,20 +509,7 @@ describe("Phase 7: Edge Cases", () => {
expect(stderr).toMatchSnapshot();
});
test("7.5 schema put invalid schema errors", async () => {
const badSchemaFile = join(tmpStore, "bad-schema.json");
writeFileSync(badSchemaFile, JSON.stringify({ type: "invalid" }));
const { stdout, stderr, exitCode } = await runCli([
"schema",
"put",
badSchemaFile,
]);
expect(exitCode).not.toBe(0);
expect(stdout).toBe("");
expect(stderr).toMatchSnapshot();
});
test("7.6 no subcommand shows help text", async () => {
test("7.5 no subcommand shows help text", async () => {
const { stdout, stderr, exitCode: _exitCode } = await runCli([]);
const combined = stdout + stderr;
expect(combined.length).toBeGreaterThan(0);
@@ -531,14 +517,15 @@ describe("Phase 7: Edge Cases", () => {
expect(combined.toLowerCase()).toContain("usage");
});
test("7.7 --store non-existent path errors with Store not found", async () => {
const fakeStore = "/nonexistent/store/path";
test("7.6 --store path is a file errors", async () => {
const fileAsStore = join(tmpStore, "not-a-directory");
writeFileSync(fileAsStore, "test");
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
fakeStore,
fileAsStore,
"--var-db",
varDbPath,
"get",
@@ -549,7 +536,95 @@ describe("Phase 7: Edge Cases", () => {
const exitCode = await proc.exited;
const stderr = (await new Response(proc.stderr).text()).trim();
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
expect(stderr).not.toContain("Node not found");
expect(stderr).toContain("not a directory");
});
});
// ---- Phase 8: Pipe Composition ----
//
// Every JSON command emits a { type, value } envelope, so its stdout can be
// fed straight into `render --pipe` (which renders the envelope value) or into
// any downstream JSON consumer. These tests verify the envelopes compose
// end-to-end.
describe("Phase 8: Pipe Composition", () => {
test("8.1 put | render -p expands the stored hash to its content", async () => {
const nodeFile = join(tmpStore, "pipe-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Bob", age: 42 }));
const { stdout: putOut, exitCode: putExit } = await runCli([
"put",
typeHash,
nodeFile,
]);
expect(putExit).toBe(0);
// The put envelope value is a cas_ref hash; render -p dereferences it and
// renders the stored node's payload.
const { stdout, exitCode } = await runCliWithStdin(
["render", "--pipe"],
putOut,
);
expect(exitCode).toBe(0);
expect(stdout).toContain("Bob");
});
test("8.2 gc | render -p renders the gc stats", async () => {
const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]);
expect(gcExit).toBe(0);
const { stdout, exitCode } = await runCliWithStdin(
["render", "--pipe"],
gcOut,
);
expect(exitCode).toBe(0);
// gc value is an object { total, reachable, collected, scanned }
expect(stdout).toContain("total:");
});
test("8.3 list --type @schema emits a parseable envelope of hashes", async () => {
const { stdout, exitCode } = await runCli(["list", "--type", "@schema"]);
expect(exitCode).toBe(0);
// Downstream consumers (jq, etc.) read the `value` array of hashes.
const value = envValue(stdout) as string[];
expect(Array.isArray(value)).toBe(true);
for (const hash of value) {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("8.4 list --type @schema | render -p expands the schema list", async () => {
const { stdout: listOut } = await runCli(["list", "--type", "@schema"]);
// list result items are cas_ref hashes; render -p dereferences each one
// and renders the schema contents.
const { stdout, exitCode } = await runCliWithStdin(
["render", "--pipe"],
listOut,
);
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
test("8.5 render <hash> uses a registered template", async () => {
// Register a template for the schema, then render a fresh node by hash.
const tmplFile = join(tmpStore, "pipe-render.liquid");
writeFileSync(tmplFile, "Person: {{ payload.name }} ({{ payload.age }})");
const { exitCode: setExit } = await runCli([
"template",
"set",
typeHash,
tmplFile,
]);
expect(setExit).toBe(0);
const nodeFile = join(tmpStore, "pipe-render-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Carol", age: 25 }));
const { stdout: putOut } = await runCli(["put", typeHash, nodeFile]);
const freshHash = envValue(putOut) as string;
const { stdout, exitCode } = await runCli(["render", freshHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Person: Carol (25)");
});
});
+118 -288
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bun
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
import type { Hash, Store, VariableStore } from "@uncaged/json-cas";
import {
bootstrap,
CasNodeNotFoundError,
@@ -13,18 +13,17 @@ import {
getSchema,
InvalidTagFormatError,
InvalidVariableNameError,
putSchema,
refs,
renderAsync,
renderDirect,
SchemaValidationError,
TagLabelConflictError,
VariableNotFoundError,
validate,
verify,
walk,
wrapEnvelope,
} from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
// ---- Argument parsing ----
@@ -41,6 +40,7 @@ const VALUE_FLAGS = new Set([
"decay",
"epsilon",
"inline",
"type",
]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
@@ -113,26 +113,16 @@ function readJsonFile(file: string): unknown {
}
/**
* Validate that the store directory exists.
* Dies with a clear error message if not found.
* Open the filesystem-backed CAS store.
* Automatically creates directory and bootstraps if needed.
*/
function validateStoreExists(path: string): void {
if (!existsSync(path)) {
die(`Store not found at ${path}`);
}
}
function openStore(shouldCreate = false): Store {
async function openStore(): Promise<Store> {
const fullPath = resolve(storePath);
if (!shouldCreate) {
validateStoreExists(fullPath);
}
return createFsStore(fullPath);
return await openFsStore(fullPath);
}
function openVarStore(): VariableStore {
const store = openStore(true);
mkdirSync(resolve(storePath), { recursive: true });
async function openVarStore(): Promise<VariableStore> {
const store = await openStore();
return createVariableStore(resolve(varDbPath), store);
}
@@ -141,12 +131,9 @@ function openVarStore(): VariableStore {
* If the input starts with @, resolve it via bootstrap
* Otherwise, return the hash as-is
*/
async function resolveTypeHash(
typeHashOrAlias: string,
shouldCreate = false,
): Promise<Hash> {
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
if (typeHashOrAlias.startsWith("@")) {
const store = openStore(shouldCreate);
const store = await openStore();
const builtinSchemas = await bootstrap(store);
const resolvedHash = builtinSchemas[typeHashOrAlias];
if (!resolvedHash) {
@@ -157,55 +144,6 @@ async function resolveTypeHash(
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[] }
@@ -240,85 +178,14 @@ function parseTagsLabels(args: string[]): {
// ---- Commands ----
async function cmdInit(): Promise<void> {
const dir = resolve(storePath);
mkdirSync(dir, { recursive: true });
const store = createFsStore(dir);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
console.log(metaHash);
}
async function cmdBootstrap(): Promise<void> {
const store = openStore(true);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
console.log(metaHash);
}
async function cmdSchemaPut(args: string[]): Promise<void> {
const file = args[0];
if (!file) die("Usage: json-cas schema put <file.json>");
const schema = readJsonFile(file) as JSONSchema;
const store = openStore(true);
try {
const hash = await putSchema(store, schema);
console.log(hash);
} catch (e) {
if (e instanceof SchemaValidationError) {
die(e.message);
}
throw e;
}
}
async function cmdSchemaGet(args: string[]): Promise<void> {
const hashOrAlias = args[0];
if (!hashOrAlias) die("Usage: json-cas schema get <type-hash>");
const hash = await resolveTypeHash(hashOrAlias);
const store = openStore();
const schema = getSchema(store, hash);
if (schema === null) die(`Schema not found: ${hashOrAlias}`);
out(schema);
}
async function cmdSchemaList(): Promise<void> {
const store = openStore();
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (!metaHash) throw new Error("Meta-schema not found");
for (const hash of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null) {
const schema = node.payload as JSONSchema;
const name =
(schema.title as string | undefined) ??
(schema.description as string | undefined) ??
"(unnamed)";
console.log(`${hash} ${name}`);
}
}
}
async function cmdSchemaValidate(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas schema validate <hash>");
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const valid = validate(store, node);
console.log(valid ? "valid" : "invalid");
}
async function cmdPut(args: string[]): Promise<void> {
const typeHashOrAlias = args[0];
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas put <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = readJsonFile(file);
const store = openStore(true);
const store = await openStore();
// Check if schema exists
const schema = getSchema(store, typeHash);
@@ -337,51 +204,55 @@ async function cmdPut(args: string[]): Promise<void> {
}
const hash = await store.put(typeHash, payload);
console.log(hash);
out(await wrapEnvelope(store, "@output/put", hash));
}
async function cmdGet(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas get <hash>");
const store = openStore();
const store = await openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
out(node);
out(await wrapEnvelope(store, "@output/get", node));
}
async function cmdHas(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas has <hash>");
const store = openStore();
console.log(String(store.has(hash)));
const store = await openStore();
out(await wrapEnvelope(store, "@output/has", store.has(hash)));
}
async function cmdVerify(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas verify <hash>");
const store = openStore();
const store = await openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const ok = await verify(hash, node);
console.log(ok ? "ok" : "corrupted");
let status: string;
if (!ok) {
status = "corrupted";
} else {
status = validate(store, node) ? "ok" : "invalid";
}
out(await wrapEnvelope(store, "@output/verify", status));
}
async function cmdRefs(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas refs <hash>");
const store = openStore();
const store = await openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const refHashes = refs(store, node);
for (const r of refHashes) {
console.log(r);
}
out(await wrapEnvelope(store, "@output/refs", refHashes));
}
async function cmdWalk(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas walk <hash> [--format tree]");
const store = openStore();
const store = await openStore();
const format = flags.format;
if (format === "tree") {
@@ -391,15 +262,16 @@ 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)) {
console.log(`${prefix}${connector}${h} (seen)`);
lines.push(`${prefix}${connector}${h} (seen)`);
return;
}
printed.add(h);
console.log(`${prefix}${connector}${h}`);
lines.push(`${prefix}${connector}${h}`);
const kids = childMap.get(h) ?? [];
const childPrefix =
@@ -410,10 +282,13 @@ 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) => {
console.log(h);
hashes.push(h);
});
out(await wrapEnvelope(store, "@output/walk", hashes));
}
}
@@ -422,10 +297,11 @@ async function cmdHash(args: string[]): Promise<void> {
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas hash <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = readJsonFile(file);
const hash = await computeHash(typeHash, payload);
console.log(hash);
const store = await openStore();
out(await wrapEnvelope(store, "@output/hash", hash));
}
async function cmdRender(args: string[]): Promise<void> {
@@ -442,7 +318,7 @@ async function cmdRender(args: string[]): Promise<void> {
);
}
const store = openStore();
const store = await openStore();
// Parse numeric options
const resolution =
@@ -517,7 +393,7 @@ async function cmdRender(args: string[]): Promise<void> {
);
process.stdout.write(output);
} else {
const varStore = openVarStore();
const varStore = await openVarStore();
const output = await renderAsync(store, hash, {
resolution,
decay,
@@ -538,19 +414,6 @@ async function cmdRender(args: string[]): Promise<void> {
}
}
async function cmdCat(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas cat <hash>");
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
if (flags.payload === true) {
out(node.payload);
} else {
out(node);
}
}
async function cmdVarSet(args: string[]): Promise<void> {
const name = args[0];
const value = args[1];
@@ -560,7 +423,8 @@ async function cmdVarSet(args: string[]): Promise<void> {
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
}
const varStore = openVarStore();
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
try {
// Parse tags/labels from --tag flags
@@ -587,8 +451,7 @@ async function cmdVarSet(args: string[]): Promise<void> {
: undefined;
const variable = varStore.set(name, value, options);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
out(await wrapEnvelope(store, "@output/var-set", variable));
} catch (e) {
if (
e instanceof InvalidVariableNameError ||
@@ -611,15 +474,15 @@ async function cmdVarGet(args: string[]): Promise<void> {
die("Usage: json-cas var get <name> --schema <hash>");
}
const varStore = openVarStore();
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
try {
const variable = varStore.get(name, schema);
if (variable === null) {
die(`Error: Variable not found: name=${name}, schema=${schema}`);
}
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
out(await wrapEnvelope(store, "@output/var-get", variable));
} finally {
varStore.close();
}
@@ -633,19 +496,18 @@ async function cmdVarDelete(args: string[]): Promise<void> {
die("Usage: json-cas var delete <name> [--schema <hash>]");
}
const varStore = openVarStore();
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
try {
if (schema !== undefined) {
// Precise deletion: remove specific (name, schema) variant
const variable = varStore.remove(name, schema);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
out(await wrapEnvelope(store, "@output/var-delete", variable));
} else {
// Batch deletion: remove all variants for this name
const variables = varStore.remove(name);
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
out(await wrapEnvelope(store, "@output/var-delete", variables));
}
} catch (e) {
if (e instanceof VariableNotFoundError) {
@@ -670,7 +532,8 @@ async function cmdVarTag(args: string[]): Promise<void> {
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const varStore = openVarStore();
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
try {
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
@@ -681,8 +544,7 @@ async function cmdVarTag(args: string[]): Promise<void> {
delete: deleteNames.length > 0 ? deleteNames : undefined,
});
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
out(await wrapEnvelope(store, "@output/var-tag", variable));
} catch (e) {
if (
e instanceof VariableNotFoundError ||
@@ -702,7 +564,8 @@ async function cmdVarList(args: string[]): Promise<void> {
const schema = flags.schema as string | undefined;
const tagFlags = flags.tag;
const varStore = openVarStore();
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
try {
// Parse tags/labels from --tag flags
@@ -724,8 +587,7 @@ async function cmdVarList(args: string[]): Promise<void> {
tags: Object.keys(tags).length > 0 ? tags : undefined,
labels: labels.length > 0 ? labels : undefined,
});
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
out(await wrapEnvelope(store, "@output/var-list", variables));
} catch (e) {
if (e instanceof InvalidVariableNameError) {
die(`Error: ${e.message}`);
@@ -744,8 +606,7 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
}
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -795,10 +656,12 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
const varName = `@ucas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
out({
schemaHash,
contentHash,
});
out(
await wrapEnvelope(store, "@output/template-set", {
schemaHash,
contentHash,
}),
);
} catch (e) {
if (e instanceof CasNodeNotFoundError) {
die(`Error: ${e.message}`);
@@ -816,8 +679,7 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
die("Usage: json-cas template get <schema-hash>");
}
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -835,16 +697,16 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
die(`Error: Content not found in CAS: ${variable.value}`);
}
// Output raw text (not JSON)
process.stdout.write(node.payload as string);
out(
await wrapEnvelope(store, "@output/template-get", node.payload as string),
);
} finally {
varStore.close();
}
}
async function cmdTemplateList(_args: string[]): Promise<void> {
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -854,24 +716,12 @@ async function cmdTemplateList(_args: string[]): Promise<void> {
schema: stringHash,
});
const templates = variables.map((v) => {
const schemaHash = v.name.replace("@ucas/template/text/", "");
const templates = variables.map((v) => ({
schemaHash: v.name.replace("@ucas/template/text/", ""),
contentHash: v.value,
}));
// 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);
out(await wrapEnvelope(store, "@output/template-list", templates));
} finally {
varStore.close();
}
@@ -884,8 +734,7 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
die("Usage: json-cas template delete <schema-hash>");
}
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -893,7 +742,9 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
const stringHash = await resolveTypeHash("@string");
varStore.remove(varName, stringHash);
out({ deleted: true });
out(
await wrapEnvelope(store, "@output/template-delete", { deleted: true }),
);
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: Template not found for schema: ${schemaHash}`);
@@ -905,48 +756,56 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
}
async function cmdGc(_args: string[]): Promise<void> {
const store = createFsStore(storePath);
const store = await openStore();
const varStore = createVariableStore(varDbPath, store);
try {
const stats = gc(store, varStore);
out(stats);
out(await wrapEnvelope(store, "@output/gc", 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));
}
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:
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
put <type-hash> <file.json> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
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)
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)
@@ -971,35 +830,6 @@ if (!cmd) {
}
switch (cmd) {
case "init":
await cmdInit();
break;
case "bootstrap":
await cmdBootstrap();
break;
case "schema": {
const [sub, ...subRest] = rest;
switch (sub) {
case "put":
await cmdSchemaPut(subRest);
break;
case "get":
await cmdSchemaGet(subRest);
break;
case "list":
await cmdSchemaList();
break;
case "validate":
await cmdSchemaValidate(subRest);
break;
default:
die(`Unknown schema subcommand: ${sub ?? "(none)"}`);
}
break;
}
case "put":
await cmdPut(rest);
break;
@@ -1032,8 +862,8 @@ switch (cmd) {
await cmdRender(rest);
break;
case "cat":
await cmdCat(rest);
case "list":
await cmdList(rest);
break;
case "var": {
+62 -71
View File
@@ -103,9 +103,10 @@ describe("template set", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("contentHash");
expect(envelope.value.schemaHash).toBe(stringHash);
});
test("set template with --inline flag", async () => {
@@ -122,9 +123,10 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("contentHash");
expect(envelope.value.schemaHash).toBe(stringHash);
});
test("update existing template (idempotent)", async () => {
@@ -148,12 +150,12 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
const envelope = JSON.parse(stdout);
expect(envelope.value).toHaveProperty("contentHash");
// Verify we can get the new version
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe("Version 2");
expect(JSON.parse(getOut).value).toBe("Version 2");
});
test("error when file not found", async () => {
@@ -223,7 +225,7 @@ describe("template set", () => {
// Verify content
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe(multilineContent);
expect(JSON.parse(getOut).value).toBe(multilineContent);
});
test("support empty templates", async () => {
@@ -240,8 +242,8 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
const envelope = JSON.parse(stdout);
expect(envelope.value).toHaveProperty("contentHash");
});
test("error when neither file nor --inline provided", async () => {
@@ -271,12 +273,12 @@ describe("template set", () => {
// Verify content preserved
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe(specialContent);
expect(JSON.parse(getOut).value).toBe(specialContent);
});
});
describe("template get", () => {
test("retrieve template as raw text", async () => {
test("retrieve template as envelope value", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
@@ -291,7 +293,9 @@ describe("template get", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toBe(content);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toBe(content);
});
test("error when template not found", async () => {
@@ -309,27 +313,26 @@ describe("template get", () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Note: runCli helper trims stdout, so we test with content that doesn't have leading/trailing whitespace
// The actual CLI preserves whitespace correctly
// The envelope's value preserves exact whitespace (JSON-escaped),
// so trimming the surrounding JSON output is harmless.
const content = "spaces\n\ttabs\t\nmixed";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout } = await runCli("template", "get", stringHash);
expect(stdout).toBe(content);
expect(JSON.parse(stdout).value).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(stdout).toBe(multiline);
expect(JSON.parse(stdout).value).toBe(multiline);
});
});
@@ -346,34 +349,40 @@ describe("template list", () => {
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBeGreaterThanOrEqual(1);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(Array.isArray(envelope.value)).toBe(true);
expect(envelope.value.length).toBeGreaterThanOrEqual(1);
// Check structure
const item = output[0];
const item = envelope.value[0];
expect(item).toHaveProperty("schemaHash");
expect(item).toHaveProperty("preview");
expect(item).toHaveProperty("contentHash");
});
test("preview truncation for long content", async () => {
test("entry contentHash matches set result", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const longContent = "a".repeat(200);
await runCli("template", "set", stringHash, "--inline", longContent);
const { stdout: setOut } = await runCli(
"template",
"set",
stringHash,
"--inline",
"Some template content",
);
const { contentHash } = JSON.parse(setOut).value;
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
const value = JSON.parse(stdout).value as Array<{
schemaHash: string;
preview: string;
contentHash: string;
}>;
const item = output.find((i) => i.schemaHash === stringHash);
const item = value.find((i) => i.schemaHash === stringHash);
expect(item).toBeDefined();
if (item) {
expect(item.preview.length).toBeLessThan(longContent.length);
expect(item.preview).toContain("...");
expect(item.contentHash).toBe(contentHash);
}
});
@@ -382,9 +391,9 @@ describe("template list", () => {
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBe(0);
const envelope = JSON.parse(stdout);
expect(Array.isArray(envelope.value)).toBe(true);
expect(envelope.value.length).toBe(0);
});
test("exclude non-template variables", async () => {
@@ -400,14 +409,14 @@ describe("template list", () => {
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout);
const envelope = JSON.parse(stdout);
// Should only contain template variables
for (const item of output) {
for (const item of envelope.value) {
expect(item.schemaHash).toBeDefined();
}
});
test("output JSON array format", async () => {
test("output JSON envelope with array value", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
@@ -418,27 +427,8 @@ describe("template list", () => {
// Should be valid JSON
expect(() => JSON.parse(stdout)).not.toThrow();
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");
}
const envelope = JSON.parse(stdout);
expect(Array.isArray(envelope.value)).toBe(true);
});
});
@@ -458,9 +448,10 @@ describe("template delete", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("deleted");
expect(output.deleted).toBe(true);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("deleted");
expect(envelope.value.deleted).toBe(true);
// Verify template is gone
const { exitCode: getExitCode } = await runCli(
@@ -495,13 +486,13 @@ describe("template delete", () => {
// Verify second still exists
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
const value = JSON.parse(stdout).value as Array<{
schemaHash: string;
preview: string;
contentHash: string;
}>;
// Should not find deleted template
const deleted = output.find((i) => i.schemaHash === stringHash);
const deleted = value.find((i) => i.schemaHash === stringHash);
expect(deleted).toBeUndefined();
});
@@ -519,7 +510,7 @@ describe("template delete", () => {
"--inline",
"Content",
);
const { contentHash } = JSON.parse(setOut);
const { contentHash } = JSON.parse(setOut).value;
// Delete the template variable
await runCli("template", "delete", stringHash);
@@ -577,7 +568,7 @@ describe("template integration", () => {
stringHash,
);
expect(getExit).toBe(0);
expect(getOut).toBe(content);
expect(JSON.parse(getOut).value).toBe(content);
// List
const { stdout: listOut, exitCode: listExit } = await runCli(
@@ -585,7 +576,7 @@ describe("template integration", () => {
"list",
);
expect(listExit).toBe(0);
const listData = JSON.parse(listOut);
const listData = JSON.parse(listOut).value;
expect(listData.length).toBeGreaterThan(0);
// Delete
@@ -626,8 +617,8 @@ describe("template integration", () => {
// List should show all
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout);
expect(output.length).toBeGreaterThanOrEqual(1);
const value = JSON.parse(stdout).value;
expect(value.length).toBeGreaterThanOrEqual(1);
});
});
+1 -1
View File
@@ -1 +1 @@
export { createFsStore } from "./store.js";
export { createFsStore, openStore } from "./store.js";
+120 -3
View File
@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasNode } from "@uncaged/json-cas";
@@ -10,7 +16,7 @@ import {
verify,
} from "@uncaged/json-cas";
import { createFsStore } from "./store.js";
import { createFsStore, openStore } from "./store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "json-cas-fs-test-"));
@@ -59,7 +65,7 @@ describe("createFsStore – init and bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
});
});
@@ -312,3 +318,114 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// openStore – async with auto-bootstrap
// ──────────────────────────────────────────────────────────────────────────────
describe("openStore – async with auto-bootstrap", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("openStore returns Promise<Store>", async () => {
const store = await openStore(dir);
expect(store).toBeDefined();
expect(typeof store.put).toBe("function");
expect(typeof store.get).toBe("function");
});
test("openStore auto-creates directory when it doesn't exist", async () => {
const nested = join(dir, "sub", "nested", "store");
expect(existsSync(nested)).toBe(false);
const store = await openStore(nested);
expect(existsSync(nested)).toBe(true);
// Verify store works
const typeHash = await computeSelfHash({ name: "t" });
const hash = await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
});
test("openStore works when directory already exists", async () => {
// Pre-create the directory
const store1 = await openStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
await store1.put(typeHash, { x: 1 });
// Open again
const store2 = await openStore(dir);
expect(store2.listByType(typeHash)).toHaveLength(1);
});
test("openStore throws error when path exists but is not a directory", async () => {
const filePath = join(dir, "not-a-dir");
writeFileSync(filePath, "test");
await expect(openStore(filePath)).rejects.toThrow();
});
test("openStore auto-bootstraps on first open (empty directory)", async () => {
const store = await openStore(dir);
// Check that bootstrap schemas exist
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
expect(metaHash).toBeDefined();
expect(store.has(metaHash as string)).toBe(true);
// Verify all core schemas exist
expect(store.has(builtinSchemas["@string"] as string)).toBe(true);
expect(store.has(builtinSchemas["@number"] as string)).toBe(true);
expect(store.has(builtinSchemas["@object"] as string)).toBe(true);
expect(store.has(builtinSchemas["@array"] as string)).toBe(true);
expect(store.has(builtinSchemas["@bool"] as string)).toBe(true);
expect(store.has(builtinSchemas["@schema"] as string)).toBe(true);
});
test("openStore bootstrap is idempotent on subsequent opens", async () => {
const store1 = await openStore(dir);
const schemas1 = await bootstrap(store1);
const count1 = store1.listAll().length;
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
const count2 = store2.listAll().length;
// Same schemas, same count
expect(schemas1).toEqual(schemas2);
expect(count1).toBe(count2);
});
test("openStore works on already-bootstrapped store", async () => {
// Bootstrap manually first
const store1 = createFsStore(dir);
const schemas1 = await bootstrap(store1);
// Open with openStore
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
expect(schemas1).toEqual(schemas2);
});
test("openStore auto-bootstraps old store without bootstrap", async () => {
// Create a store with some data but no bootstrap
const store1 = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "custom" });
await store1.put(typeHash, { data: "old" });
// Open with openStore - should auto-bootstrap
const store2 = await openStore(dir);
const schemas = await bootstrap(store2);
expect(store2.has(schemas["@schema"] as string)).toBe(true);
// Old data still exists
expect(store2.listByType(typeHash)).toHaveLength(1);
});
});
+56
View File
@@ -5,6 +5,7 @@ import {
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
@@ -13,6 +14,7 @@ import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
bootstrap,
cborEncode,
computeHash,
computeSelfHash,
@@ -219,3 +221,57 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return store;
}
/**
* Open a filesystem-backed CAS store with automatic directory creation and bootstrap.
* This is an async function that:
* 1. Creates the directory (with recursive: true) if it doesn't exist
* 2. Validates that the path is actually a directory (not a file)
* 3. Creates the store
* 4. Runs bootstrap (which is idempotent)
*
* @param dir - The directory path for the store
* @returns A Promise resolving to the BootstrapCapableStore
* @throws Error if the path exists but is not a directory
*/
export async function openStore(dir: string): Promise<BootstrapCapableStore> {
// Create directory if it doesn't exist
try {
mkdirSync(dir, { recursive: true });
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "EACCES") {
throw new Error(`Permission denied: cannot access store at ${dir}`);
}
if (nodeError.code === "ENOTDIR") {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
}
throw error;
}
// Validate that the path is a directory
try {
const stats = statSync(dir);
if (!stats.isDirectory()) {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
throw new Error(`Store not found at ${dir}`);
}
}
throw error;
}
// Create the store
const store = createFsStore(dir);
// Bootstrap (idempotent)
await bootstrap(store);
return store;
}
+191 -2
View File
@@ -1,18 +1,40 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import type { JSONSchema } from "./schema.js";
import { getSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
const OUTPUT_ALIASES = [
"@output/put",
"@output/get",
"@output/has",
"@output/hash",
"@output/verify",
"@output/refs",
"@output/walk",
"@output/list",
"@output/var-set",
"@output/var-get",
"@output/var-delete",
"@output/var-tag",
"@output/var-list",
"@output/template-set",
"@output/template-get",
"@output/template-list",
"@output/template-delete",
"@output/gc",
] as const;
// ──────────────────────────────────────────────────────────────────────────────
// Built-in Schema Registration Tests
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of built-in schema aliases to hashes", async () => {
test("should return map of 24 built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
// Should return object with 6 aliases
// Should return object with 6 primitive + 18 output aliases = 24
expect(builtinSchemas).toHaveProperty("@schema");
expect(builtinSchemas).toHaveProperty("@string");
expect(builtinSchemas).toHaveProperty("@number");
@@ -20,6 +42,12 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty("@array");
expect(builtinSchemas).toHaveProperty("@bool");
for (const alias of OUTPUT_ALIASES) {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(24);
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
expect(typeof hash).toBe("string");
@@ -127,3 +155,164 @@ describe("bootstrap - Built-in Schemas", () => {
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// @output/* Schema Registration Tests
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - @output/* Schemas", () => {
test("each @output/* schema has a title", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
for (const alias of OUTPUT_ALIASES) {
const hash = aliases[alias];
if (!hash) throw new Error(`${alias} not found`);
const schema = getSchema(store, hash) as JSONSchema;
expect(schema).not.toBeNull();
expect(typeof schema.title).toBe("string");
expect((schema.title as string).startsWith("ucas ")).toBe(true);
}
});
test("@output/put schema describes a cas_ref string", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/put"];
if (!hash) throw new Error("@output/put not found");
const schema = getSchema(store, hash);
expect(schema).toEqual({
type: "string",
format: "cas_ref",
title: "ucas put result",
});
});
test("@output/get schema describes object with type, payload, timestamp", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/get"];
if (!hash) throw new Error("@output/get not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas get result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.type).toEqual({ type: "string", format: "cas_ref" });
expect(props.payload).toEqual({});
expect(props.timestamp).toEqual({ type: "number" });
});
test("@output/has schema describes a boolean", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/has"];
if (!hash) throw new Error("@output/has not found");
expect(getSchema(store, hash)).toEqual({
type: "boolean",
title: "ucas has result",
});
});
test("@output/verify schema describes enum of ok|corrupted|invalid", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/verify"];
if (!hash) throw new Error("@output/verify not found");
const schema = getSchema(store, hash);
expect(schema).toEqual({
type: "string",
enum: ["ok", "corrupted", "invalid"],
title: "ucas verify result",
});
});
test("@output/refs schema describes array of cas_ref strings", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/refs"];
if (!hash) throw new Error("@output/refs not found");
expect(getSchema(store, hash)).toEqual({
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas refs result",
});
});
test("@output/gc schema describes object with gc stats fields", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/gc"];
if (!hash) throw new Error("@output/gc not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas gc result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.total).toEqual({ type: "number" });
expect(props.reachable).toEqual({ type: "number" });
expect(props.collected).toEqual({ type: "number" });
expect(props.scanned).toEqual({ type: "number" });
});
test("@output/var-set schema describes a Variable object", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/var-set"];
if (!hash) throw new Error("@output/var-set not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas var set result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.name).toEqual({ type: "string" });
expect(props.schema).toEqual({ type: "string", format: "cas_ref" });
expect(props.value).toEqual({ type: "string", format: "cas_ref" });
});
test("@output/var-list schema describes array of Variable objects", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/var-list"];
if (!hash) throw new Error("@output/var-list not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("array");
expect(schema.title).toBe("ucas var list result");
const items = schema.items as JSONSchema;
expect(items.type).toBe("object");
const props = items.properties as Record<string, JSONSchema>;
expect(props.name).toEqual({ type: "string" });
});
test("@output/template-delete schema describes object with deleted boolean", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/template-delete"];
if (!hash) throw new Error("@output/template-delete not found");
expect(getSchema(store, hash)).toEqual({
type: "object",
properties: { deleted: { type: "boolean" } },
title: "ucas template delete result",
});
});
test("all @output/* schemas are distinct hashes", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const outputHashes = OUTPUT_ALIASES.map((alias) => aliases[alias]);
const uniqueHashes = new Set(outputHashes);
expect(uniqueHashes.size).toBe(OUTPUT_ALIASES.length);
});
});
+168 -3
View File
@@ -63,9 +63,168 @@ const BOOTSTRAP_PAYLOAD = {
},
} as const;
const VARIABLE_PROPERTIES = {
name: { type: "string" },
schema: { type: "string", format: "cas_ref" },
value: { type: "string", format: "cas_ref" },
created: { type: "number" },
updated: { type: "number" },
tags: { type: "object" },
labels: { type: "array", items: { type: "string" } },
} as const;
const OUTPUT_SCHEMAS: ReadonlyArray<
readonly [alias: string, schema: Record<string, unknown>]
> = [
[
"@output/put",
{ type: "string", format: "cas_ref", title: "ucas put result" },
],
[
"@output/get",
{
type: "object",
properties: {
type: { type: "string", format: "cas_ref" },
payload: {},
timestamp: { type: "number" },
},
title: "ucas get result",
},
],
["@output/has", { type: "boolean", title: "ucas has result" }],
[
"@output/hash",
{ type: "string", format: "cas_ref", title: "ucas hash result" },
],
[
"@output/verify",
{
type: "string",
enum: ["ok", "corrupted", "invalid"],
title: "ucas verify result",
},
],
[
"@output/refs",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas refs result",
},
],
[
"@output/walk",
{
type: "array",
items: { type: "string" },
title: "ucas walk result",
},
],
[
"@output/list",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas list result",
},
],
[
"@output/var-set",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var set result",
},
],
[
"@output/var-get",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var get result",
},
],
[
"@output/var-delete",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var delete result",
},
],
[
"@output/var-tag",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var tag result",
},
],
[
"@output/var-list",
{
type: "array",
items: { type: "object", properties: { ...VARIABLE_PROPERTIES } },
title: "ucas var list result",
},
],
[
"@output/template-set",
{
type: "object",
properties: {
schemaHash: { type: "string", format: "cas_ref" },
contentHash: { type: "string", format: "cas_ref" },
},
title: "ucas template set result",
},
],
[
"@output/template-get",
{ type: "string", title: "ucas template get result" },
],
[
"@output/template-list",
{
type: "array",
items: {
type: "object",
properties: {
schemaHash: { type: "string", format: "cas_ref" },
contentHash: { type: "string", format: "cas_ref" },
},
},
title: "ucas template list result",
},
],
[
"@output/template-delete",
{
type: "object",
properties: { deleted: { type: "boolean" } },
title: "ucas template delete result",
},
],
[
"@output/gc",
{
type: "object",
properties: {
total: { type: "number" },
reachable: { type: "number" },
collected: { type: "number" },
scanned: { type: "number" },
},
title: "ucas gc result",
},
],
];
/**
* Write the meta-schema seed node into the store and register built-in schemas.
* The returned object contains aliases for the meta-schema and 5 primitive schemas.
* The returned object contains aliases for the meta-schema, 5 primitive schemas,
* and 18 @output/* schemas (24 total).
* Idempotent: calling bootstrap multiple times returns the same hashes.
*/
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
@@ -83,8 +242,8 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
const arrayHash = await store.put(metaHash, { type: "array" });
const boolHash = await store.put(metaHash, { type: "boolean" });
// 3. Return map of aliases to hashes
return {
// 3. Register @output/* schemas
const aliases: Record<string, Hash> = {
"@schema": metaHash,
"@string": stringHash,
"@number": numberHash,
@@ -92,4 +251,10 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
"@array": arrayHash,
"@bool": boolHash,
};
for (const [alias, schema] of OUTPUT_SCHEMAS) {
aliases[alias] = await store.put(metaHash, schema);
}
return aliases;
}
+5 -3
View File
@@ -264,7 +264,7 @@ describe("bootstrap", () => {
);
});
test("returns a map with 6 built-in schema aliases", async () => {
test("returns a map with 24 built-in schema aliases", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
@@ -280,6 +280,8 @@ describe("bootstrap", () => {
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(Object.keys(builtinSchemas)).toHaveLength(24);
});
test("meta-schema node is stored and retrievable", async () => {
@@ -316,7 +318,7 @@ describe("bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
// All 6 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
// All 24 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
});
});
+2
View File
@@ -5,6 +5,7 @@ export { cborEncode } from "./cbor.js";
export { type GcStats, gc } from "./gc.js";
export { computeHash, computeSelfHash } from "./hash.js";
export { renderWithTemplate } from "./liquid-render.js";
export { registerOutputTemplates } from "./output-templates.js";
export {
type RenderOptions,
render,
@@ -34,3 +35,4 @@ export {
VariableStore,
} from "./variable-store.js";
export { verify } from "./verify.js";
export { wrapEnvelope } from "./wrap-envelope.js";
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap } from "./bootstrap.js";
import { registerOutputTemplates } from "./output-templates.js";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
import { createVariableStore } from "./variable-store.js";
const OUTPUT_ALIASES = [
"@output/put",
"@output/get",
"@output/has",
"@output/hash",
"@output/verify",
"@output/refs",
"@output/walk",
"@output/list",
"@output/var-set",
"@output/var-get",
"@output/var-delete",
"@output/var-tag",
"@output/var-list",
"@output/template-set",
"@output/template-get",
"@output/template-list",
"@output/template-delete",
"@output/gc",
] as const;
describe("registerOutputTemplates", () => {
let store: Store;
let varStore: VariableStore;
let tempDir: string;
afterEach(async () => {
varStore.close();
await rm(tempDir, { recursive: true });
});
test("registers a template for every @output/* schema", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const registered = await registerOutputTemplates(store, varStore);
expect(Object.keys(registered)).toHaveLength(18);
for (const alias of OUTPUT_ALIASES) {
expect(registered).toHaveProperty(alias);
}
});
test("each template is retrievable via @ucas/template/text/<hash>", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
const aliases = await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
await registerOutputTemplates(store, varStore);
const stringHash = aliases["@string"];
if (!stringHash) throw new Error("@string not found");
for (const alias of OUTPUT_ALIASES) {
const schemaHash = aliases[alias];
if (!schemaHash) throw new Error(`${alias} not found`);
const varName = `@ucas/template/text/${schemaHash}`;
const variable = varStore.get(varName, stringHash);
if (variable === null) throw new Error(`Variable ${varName} not found`);
const templateNode = store.get(variable.value);
if (templateNode === null)
throw new Error(`Template node ${variable.value} not found`);
expect(typeof templateNode.payload).toBe("string");
}
});
test("is idempotent — safe to call multiple times", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const first = await registerOutputTemplates(store, varStore);
const second = await registerOutputTemplates(store, varStore);
expect(first).toEqual(second);
});
test("@output/put template contains payload reference", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
const aliases = await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
await registerOutputTemplates(store, varStore);
const putHash = aliases["@output/put"];
if (!putHash) throw new Error("@output/put not found");
const stringHash = aliases["@string"];
if (!stringHash) throw new Error("@string not found");
const variable = varStore.get(`@ucas/template/text/${putHash}`, stringHash);
if (variable === null)
throw new Error("@output/put template variable not found");
const templateNode = store.get(variable.value);
if (templateNode === null) throw new Error("Template node not found");
expect(templateNode.payload).toBe("{{ payload }}");
});
});
+87
View File
@@ -0,0 +1,87 @@
import { bootstrap } from "./bootstrap.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
const DEFAULT_TEMPLATES: ReadonlyArray<
readonly [alias: string, template: string]
> = [
["@output/put", "{{ payload }}"],
[
"@output/get",
"type: {{ payload.type }}\ntimestamp: {{ payload.timestamp }}",
],
["@output/has", "{{ payload }}"],
["@output/hash", "{{ payload }}"],
["@output/verify", "{{ payload }}"],
["@output/refs", "{% for ref in payload %}{{ ref }}\n{% endfor %}"],
["@output/walk", "{% for item in payload %}{{ item }}\n{% endfor %}"],
["@output/list", "{% for item in payload %}{{ item }}\n{% endfor %}"],
[
"@output/var-set",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-get",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-delete",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-tag",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-list",
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}",
],
[
"@output/template-set",
"schemaHash: {{ payload.schemaHash }}\ncontentHash: {{ payload.contentHash }}",
],
["@output/template-get", "{{ payload }}"],
[
"@output/template-list",
"{% for t in payload %}schemaHash: {{ t.schemaHash }}\ncontentHash: {{ t.contentHash }}\n{% endfor %}",
],
["@output/template-delete", "deleted: {{ payload.deleted }}"],
[
"@output/gc",
"total: {{ payload.total }}\nreachable: {{ payload.reachable }}\ncollected: {{ payload.collected }}\nscanned: {{ payload.scanned }}",
],
];
/**
* Register default LiquidJS templates for all @output/* schemas.
* Each template is stored as a @string CAS node and bound to
* the variable `@ucas/template/text/<schema-hash>`.
*
* Idempotent: safe to call multiple times.
*/
export async function registerOutputTemplates(
store: Store,
varStore: VariableStore,
): Promise<Record<string, Hash>> {
const aliases = await bootstrap(store);
const stringHash = aliases["@string"];
if (stringHash === undefined) {
throw new Error("@string schema not found in bootstrap result");
}
const registered: Record<string, Hash> = {};
for (const [alias, template] of DEFAULT_TEMPLATES) {
const schemaHash = aliases[alias];
if (schemaHash === undefined) {
throw new Error(`Schema alias not found: ${alias}`);
}
const contentHash = await store.put(stringHash, template);
const varName = `@ucas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
registered[alias] = contentHash;
}
return registered;
}
@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { createMemoryStore } from "./store.js";
import { wrapEnvelope } from "./wrap-envelope.js";
describe("wrapEnvelope", () => {
test("resolves @output/put alias and returns envelope", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@output/put", "AAAAAAAAAAAAA");
expect(envelope.type).toBe(aliases["@output/put"]);
expect(envelope.value).toBe("AAAAAAAAAAAAA");
});
test("resolves @output/has alias with boolean value", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@output/has", true);
expect(envelope.type).toBe(aliases["@output/has"]);
expect(envelope.value).toBe(true);
});
test("resolves @output/gc alias with object value", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const gcStats = { total: 100, reachable: 80, collected: 20, scanned: 5 };
const envelope = await wrapEnvelope(store, "@output/gc", gcStats);
expect(envelope.type).toBe(aliases["@output/gc"]);
expect(envelope.value).toEqual(gcStats);
});
test("resolves primitive alias @string", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@string", "hello");
expect(envelope.type).toBe(aliases["@string"]);
expect(envelope.value).toBe("hello");
});
test("throws for unknown alias", async () => {
const store = createMemoryStore();
await bootstrap(store);
await expect(
wrapEnvelope(store, "@output/nonexistent", "value"),
).rejects.toThrow("Unknown schema alias: @output/nonexistent");
});
test("is idempotent — same alias returns same type hash", async () => {
const store = createMemoryStore();
const first = await wrapEnvelope(store, "@output/verify", "ok");
const second = await wrapEnvelope(store, "@output/verify", "corrupted");
expect(first.type).toBe(second.type);
expect(first.value).toBe("ok");
expect(second.value).toBe("corrupted");
});
test("preserves complex object values without mutation", async () => {
const store = createMemoryStore();
await bootstrap(store);
const original = {
name: "test",
schema: "AAAAAAAAAAAAA",
value: "BBBBBBBBBBBBB",
created: 1000,
updated: 2000,
tags: { env: "prod" },
labels: ["stable"],
};
const envelope = await wrapEnvelope(store, "@output/var-set", original);
expect(envelope.value).toEqual(original);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { bootstrap } from "./bootstrap.js";
import type { Hash, Store } from "./types.js";
/**
* Resolve a schema alias (e.g. "@output/put") to its hash via bootstrap,
* then return a typed envelope ready for store.put() or direct rendering.
*/
export async function wrapEnvelope(
store: Store,
schemaAlias: string,
value: unknown,
): Promise<{ type: Hash; value: unknown }> {
const aliases = await bootstrap(store);
const typeHash = aliases[schemaAlias];
if (typeHash === undefined) {
throw new Error(`Unknown schema alias: ${schemaAlias}`);
}
return { type: typeHash, value };
}