Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoju a445f2ecc5 fix: validate store path before read operations (Fixes #55)
- Add validateStoreExists() helper to check store directory existence
- Modify openStore() to accept shouldCreate parameter (default: false)
- Update read commands to validate store exists first
- Update write commands (init, put, schema put, hash) to allow creation
- Add 10 E2E tests covering all affected commands
- Improve error message: "Store not found at <path>" vs "Node not found"

Commands that now validate:
- get, has, verify, refs, walk, cat
- schema get, schema list

Commands that still create:
- init, put, schema put, hash
- var commands (use openVarStore)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:15:28 +00:00
34 changed files with 698 additions and 4386 deletions
-413
View File
@@ -1,413 +0,0 @@
name: "e2e-check"
description: "Docker-isolated E2E testing of json-cas CLI. Preparer builds from scratch, tester runs scenarios, reporter files bugs."
roles:
preparer:
description: "Spins up Docker container, copies repo, installs, builds, runs unit tests"
goal: "You set up a clean Docker environment for E2E testing. Your job is to start a container, install deps, build, lint, run unit tests, and initialize a CAS store. Report any setup failures as bugs."
capabilities:
- docker
procedure: |
1. Start a detached container:
```bash
docker run -d --name json-cas-e2e \
-v "<repoPath>:/src:ro" \
-w /workspace \
oven/bun:latest \
sleep 3600
```
2. Copy repo and install:
```bash
docker exec json-cas-e2e bash -c 'cp -r /src/. /workspace/ && cd /workspace && bun install'
```
✅ exit code 0, no missing peer deps
3. Build:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun run build'
```
✅ exit code 0, no type errors
4. Lint:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun run check'
```
✅ exit code 0
5. Unit tests:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun test'
```
✅ all pass (ignore dist/ false positives)
6. Init CAS store:
```bash
docker exec json-cas-e2e bash -c 'mkdir -p /tmp/cas-test && cd /workspace && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test init && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test bootstrap'
```
**Any failure here is a high-severity bug** — it means a clean environment can't build/run the project.
Set $status=ready if all steps pass. Set $status=setup_failed with failures list if anything breaks.
output: "Setup result summary."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "ready" }
containerName: { type: string }
storePath: { type: string }
repoPath: { type: string }
required: [$status, containerName, storePath, repoPath]
- type: object
properties:
$status: { const: "setup_failed" }
failures:
type: array
items:
type: object
properties:
title: { type: string }
command: { type: string }
expected: { type: string }
actual: { type: string }
severity: { type: string }
phase: { type: string }
required: [title, command, expected, actual, severity, phase]
repoPath: { type: string }
required: [$status, failures, repoPath]
tester:
description: "Runs CLI scenarios against the prepared Docker environment"
goal: "You are an exploratory QA agent. The Docker container is already running with the project built and a CAS store initialized. Run CLI test scenarios and report bugs."
capabilities:
- testing
- cli
procedure: |
The container `{{{containerName}}}` is already running with the project built.
Store path: `{{{storePath}}}`.
Run all commands via:
```bash
docker exec {{{containerName}}} bash -c 'cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}} <subcommand>'
```
Define a shorthand in your notes:
`CMD="cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}}"`
## Phase 1: CAS Core Operations
1. **bootstrap** — `$CMD bootstrap`
Expected: prints meta-schema hash (13-char Base32)
2. **schema put** — Create `/tmp/test-schema.json` in container, then `$CMD schema put /tmp/test-schema.json`
Schema: `{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"number"}},"required":["name"],"additionalProperties":false}`
Expected: prints type hash
3. **schema get** — `$CMD schema get <type-hash>`
Expected: returns the schema JSON
4. **schema list** — `$CMD schema list`
Expected: lists registered schemas
5. **put** — Create data file, `$CMD put <type-hash> /tmp/test-node.json`
Data: `{"name":"Alice","age":30}`
Expected: prints node hash
6. **get** — `$CMD get <node-hash>`
Expected: returns node JSON
7. **has (exists)** — `$CMD has <node-hash>`
Expected: true
8. **has (not exists)** — `$CMD has AAAAAAAAAAAAA`
Expected: false
9. **verify** — `$CMD verify <node-hash>`
Expected: ok
10. **refs** — `$CMD refs <node-hash>`
Expected: lists refs (may be empty)
11. **walk** — `$CMD walk <node-hash>`
Expected: shows traversal tree
12. **hash (dry run)** — `$CMD hash <type-hash> /tmp/test-node.json`
Expected: same hash as put
13. **cat** — `$CMD cat <node-hash>`
Expected: full node output
14. **cat --payload** — `$CMD cat <node-hash> --payload`
Expected: payload only (no type wrapper)
## Phase 2: Schema Validation
1. **Invalid node** — `$CMD put <type-hash> /tmp/bad-node.json` where bad-node = `{"name":123}`
Expected: validation error, non-zero exit
2. **schema validate** — `$CMD schema validate <node-hash>`
Expected: valid for good node
3. **Non-existent schema** — `$CMD put AAAAAAAAAAAAA /tmp/test-node.json`
Expected: error about missing schema
## Phase 3: Variable System
1. **var set** — `$CMD var set myapp/config <node-hash>`
Expected: creates variable
2. **var get** — `$CMD var get myapp/config --schema <type-hash>`
Expected: returns variable
3. **var list** — `$CMD var list`
Expected: shows all variables
4. **var list prefix** — `$CMD var list myapp/`
Expected: filtered results
5. **var set (update)** — Put a second node, `$CMD var set myapp/config <new-hash>`
Expected: upsert succeeds
6. **var tag** — `$CMD var tag myapp/config --schema <type-hash> env:prod important`
Expected: adds tag and label
7. **var list --tag** — `$CMD var list --tag env:prod`
Expected: finds tagged variable
8. **var list --tag (label)** — `$CMD var list --tag important`
Expected: finds labeled variable
9. **var tag remove** — `$CMD var tag myapp/config --schema <type-hash> :important`
Expected: removes label
10. **var delete** — `$CMD var delete myapp/config`
Expected: deletes variable
11. **var get (deleted)** — `$CMD var get myapp/config --schema <type-hash>`
Expected: not found error
## Phase 4: Template System
1. **template set** — Create template file, `$CMD template set <type-hash> /tmp/test.liquid`
Template: `Name: {{ payload.name }}, Age: {{ payload.age }}`
Expected: success
2. **template get** — `$CMD template get <type-hash>`
Expected: returns template text
3. **template list** — `$CMD template list`
Expected: lists templates
4. **template delete** — `$CMD template delete <type-hash>`
Expected: success
5. **template get (deleted)** — `$CMD template get <type-hash>`
Expected: not found error
## Phase 5: Render
1. Re-register template, then `$CMD render <node-hash>`
Expected: rendered output with payload values filled in
2. **render --resolution** — `$CMD render <node-hash> --resolution 0.5`
Expected: different resolution output
3. **render (bad hash)** — `$CMD render AAAAAAAAAAAAA`
Expected: graceful error, non-zero exit
## Phase 6: GC
1. **gc basic** — `$CMD gc`
Expected: runs without error
2. **gc preserves referenced** — Verify `$CMD has <node-hash>` still true
3. **gc collects orphans** — Put an orphan node (not in any variable), run gc, check it's gone
## Phase 7: Edge Cases & Error Handling
1. `$CMD get AAAAAAAAAAAAA` — non-existent
2. `$CMD put <type-hash> /nonexistent/file.json` — missing file
3. `$CMD var set "" <hash>` — empty name
4. `$CMD var set "bad name!" <hash>` — invalid name chars
5. `$CMD schema put /tmp/bad-schema.json` — `{"type":"invalid"}`
6. `$CMD` with no subcommand — should show help
7. `$CMD --store /nonexistent/path get <hash>` — bad store path
## Recording Results
For each scenario:
- ✅ Pass: works as expected
- ❌ Fail: unexpected behavior, crash, wrong output
- ⚠️ Questionable: works but confusing UX
Collect all ❌ and ⚠️. For each, record:
- Title (concise description)
- Command (exact command run)
- Expected behavior
- Actual behavior (include actual output)
- Severity: critical / high / medium / low
- Phase: which test phase
## CRITICAL: Frontmatter Output Format
Your response MUST start with YAML frontmatter. The `bugs` field MUST be an array of objects, NOT strings.
Example of CORRECT frontmatter:
```yaml
---
$status: bugs_found
containerName: json-cas-e2e
repoPath: /path/to/repo
bugs:
- title: "put does not validate data against schema"
command: "json-cas put <hash> bad-data.json"
expected: "Validation error, non-zero exit"
actual: "Accepted invalid data, exit 0"
severity: "high"
phase: "Schema Validation"
---
```
Do NOT write bugs as plain strings like `- some bug description`. Each bug MUST be an object with all 6 fields.
If all tests pass:
```yaml
---
$status: all_passed
containerName: json-cas-e2e
---
```
output: "Summary of all phases with pass/fail counts. Set $status."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "bugs_found" }
bugs:
type: array
items:
type: object
properties:
title: { type: string }
command: { type: string }
expected: { type: string }
actual: { type: string }
severity: { type: string }
phase: { type: string }
required: [title, command, expected, actual, severity, phase]
containerName: { type: string }
repoPath: { type: string }
required: [$status, bugs, containerName]
- type: object
properties:
$status: { const: "all_passed" }
containerName: { type: string }
required: [$status, containerName]
reporter:
description: "Opens Gitea issues for each bug found by the tester"
goal: "You are a bug reporter. You create well-formatted Gitea issues for each bug found during E2E testing."
capabilities:
- issue-management
procedure: |
1. Parse the bugs array from the tester's output
2. Group bugs by severity (critical first)
3. For each bug, create a Gitea issue:
```bash
tea issues create -r uncaged/json-cas \
-t "[E2E] <title>" \
-d "## Bug Report (E2E Check)
**Phase:** <phase>
**Severity:** <severity>
**Command:**
\`\`\`
<exact command>
\`\`\`
**Expected:** <expected>
**Actual:** <actual>
---
_Reported by e2e-check workflow (Docker isolated)_"
```
⚠️ If `tea issues create` fails with long body, use Gitea REST API:
```bash
eval "$(cfg env)" && GITEA_TOKEN=$(cfg get GITEA_TOKEN)
curl -s -X POST "https://git.shazhou.work/api/v1/repos/uncaged/json-cas/issues" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"[E2E] ...","body":"..."}'
```
4. Collect created issue numbers
output: "List created issues. Set $status."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "reported" }
issues:
type: array
items: { type: string }
required: [$status, issues]
- type: object
properties:
$status: { const: "partial" }
created: { type: number }
failed: { type: number }
required: [$status, created, failed]
cleanup:
description: "Stops and removes the Docker container"
goal: "You clean up the Docker environment after testing is complete."
capabilities:
- docker
procedure: |
Stop and remove the container:
```bash
docker stop {{{containerName}}} && docker rm {{{containerName}}}
```
Verify it's gone:
```bash
docker ps -a --filter name={{{containerName}}} --format '{{.Names}}'
```
Expected: empty output.
output: "Cleanup result."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "cleaned" }
required: [$status]
- type: object
properties:
$status: { const: "cleanup_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "preparer", prompt: "Set up Docker environment for E2E testing. Repo at {{{repoPath}}}." }
preparer:
ready: { role: "tester", prompt: "Environment ready. Container: {{{containerName}}}, store: {{{storePath}}}. Run all test scenarios." }
setup_failed: { role: "reporter", prompt: "Setup failures found. File these as bugs: {{{failures}}}" }
tester:
all_passed: { role: "cleanup", prompt: "All tests passed. Clean up container {{{containerName}}}." }
bugs_found: { role: "reporter", prompt: "File these bugs as Gitea issues: {{{bugs}}}" }
reporter:
reported: { role: "cleanup", prompt: "Bugs filed: {{{issues}}}. Clean up container {{{containerName}}}." }
partial: { role: "cleanup", prompt: "Filed {{{created}}} issues, {{{failed}}} failed. Clean up container {{{containerName}}}." }
cleanup:
cleaned: { role: "$END", prompt: "E2E check complete. Environment cleaned up." }
cleanup_failed: { role: "$END", prompt: "E2E check complete but cleanup failed: {{{error}}}" }
+11 -22
View File
@@ -51,19 +51,16 @@ roles:
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "clean" }
summary: { type: string }
required: [$status, summary]
- type: object
properties:
- properties:
$status: { const: "findings" }
report: { type: string }
targetWorkflow: { type: string }
required: [$status, report, targetWorkflow]
- type: object
properties:
- properties:
$status: { const: "wrong_project" }
workflowName: { type: string }
required: [$status, workflowName]
@@ -96,14 +93,12 @@ roles:
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- type: object
properties:
- properties:
$status: { const: "no_action" }
reason: { type: string }
required: [$status, reason]
@@ -133,14 +128,12 @@ roles:
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- type: object
properties:
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
@@ -164,14 +157,12 @@ roles:
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- type: object
properties:
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
@@ -200,13 +191,11 @@ roles:
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- type: object
properties:
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
+11 -22
View File
@@ -26,14 +26,12 @@ roles:
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- type: object
properties:
- properties:
$status: { const: "insufficient_info" }
required: [$status]
developer:
@@ -69,14 +67,12 @@ roles:
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- type: object
properties:
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
@@ -109,14 +105,12 @@ roles:
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- type: object
properties:
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
@@ -139,19 +133,16 @@ roles:
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- type: object
properties:
- properties:
$status: { const: "fix_code" }
report: { type: string }
required: [$status, report]
- type: object
properties:
- properties:
$status: { const: "fix_spec" }
report: { type: string }
required: [$status, report]
@@ -178,13 +169,11 @@ roles:
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- type: object
properties:
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
+16 -46
View File
@@ -88,60 +88,30 @@ Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/
## CLI Reference
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 }
```
Binary: `json-cas` (from `@uncaged/cli-json-cas`). Default store: `~/.uncaged/json-cas`.
```
Usage: json-cas [--store <path>] [--json] <command> [args]
Commands (all emit a { type, value } envelope unless noted):
put <type-hash> <file.json> Store node (value = hash) (@output/put)
get <hash> Node payload + metadata (@output/get)
has <hash> Existence boolean (@output/has)
verify <hash> ok / corrupted / invalid (@output/verify)
refs <hash> Direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text (raw output)
render --pipe/-p [options] Render a piped envelope (raw output)
list --type <hash-or-alias> Hashes for a type (value = list) (@output/list)
var set|get|delete|tag|list ... Variable CRUD (@output/var-*)
template set|get|list|delete ... Output-template CRUD (@output/template-*)
gc Garbage collection (@output/gc)
Commands:
init Create store dir and write bootstrap seed
bootstrap Write meta-schema seed, print hash
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
schema validate <hash> Validate node against its schema
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--json Compact JSON output
--pipe, -p Read a { type, value } envelope from stdin for render
```
### Pipe examples
```bash
# Store a node, then render the stored content (the put envelope's hash is
# a cas_ref, so render -p dereferences and renders it):
ucas put @schema ./schemas/item.json | ucas render -p
# Render garbage-collection stats:
ucas gc | ucas render -p
# List every schema, then consume the envelope's value array with jq:
ucas list --type @schema | jq -r '.value[]'
```
## Development
-28
View File
@@ -1,33 +1,5 @@
# @uncaged/cli-json-cas
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.6.0
- @uncaged/json-cas-fs@0.6.0
## 0.5.3
### Patch Changes
+31 -80
View File
@@ -4,9 +4,7 @@ CLI tool for json-cas stores.
## Overview
`@uncaged/cli-json-cas` provides the `json-cas` command (also aliased `ucas`) for managing a filesystem-backed store: node CRUD, integrity checks, reference listing, graph walks, variables, and output templates. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
The store is **auto-created and bootstrapped** on first use, so there is no `init`/`bootstrap` command. Schemas are ordinary `@schema`-typed nodes — register one with `ucas put @schema file.json` and list them with `ucas list --type @schema`; there is no dedicated `schema` subcommand.
`@uncaged/cli-json-cas` provides the `json-cas` command for managing a filesystem-backed store: bootstrap, schema registration, node CRUD, integrity checks, reference listing, and graph walks. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
@@ -39,95 +37,48 @@ Usage: json-cas [--store <path>] [--json] <command> [args]
| Flag | Description |
|------|-------------|
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
| `--var-db <path>` | Variable database path (default: `<store>/variables.db`) |
| `--json` | Compact (single-line) JSON output |
### Envelope format
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
of the command's `@output/*` result schema and `value` is the command payload. The output
is therefore self-describing and pipeable: feed any envelope into `render -p` to render its
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
raw, non-envelope text.
```jsonc
// json-cas has <hash>
{ "type": "AYHQD2YA9G667", "value": true }
// json-cas template set <schema-hash> --inline "Hi {{ payload.name }}"
{ "type": "9YJZ09DDAYAWR", "value": { "schemaHash": "7XX5H51CVD9H0", "contentHash": "FC8WACA792B6F" } }
```
| `--json` | Compact JSON output for commands that print JSON |
### Commands
| Command | Envelope `value` | Result schema |
|---------|------------------|---------------|
| `put <type-hash> <file.json>` | stored node hash (string) | `@output/put` |
| `get <hash>` | `{ type, payload, timestamp }` | `@output/get` |
| `has <hash>` | boolean | `@output/has` |
| `verify <hash>` | `ok` / `corrupted` / `invalid` | `@output/verify` |
| `refs <hash>` | hashes (string[]) | `@output/refs` |
| `walk <hash> [--format tree]` | hashes (string[]) or tree string | `@output/walk` |
| `hash <type-hash> <file.json>` | computed hash (string) | `@output/hash` |
| `render <hash> [options]` | raw text (no envelope) | — |
| `render --pipe/-p [options]` | raw text from piped envelope | — |
| `list --type <hash-or-alias>` | hashes (string[]) | `@output/list` |
| `var set <name> <hash> [--tag ...]` | variable object | `@output/var-set` |
| `var get <name> --schema <hash>` | variable object | `@output/var-get` |
| `var delete <name> [--schema <hash>]` | variable or variable[] | `@output/var-delete` |
| `var tag <name> --schema <hash> <ops...>` | variable object | `@output/var-tag` |
| `var list [prefix] [--schema <hash>] [--tag ...]` | variable[] | `@output/var-list` |
| `template set <schema-hash> <file> \| --inline <text>` | `{ schemaHash, contentHash }` | `@output/template-set` |
| `template get <schema-hash>` | template content (string) | `@output/template-get` |
| `template list` | `{ schemaHash, contentHash }[]` | `@output/template-list` |
| `template delete <schema-hash>` | `{ deleted: boolean }` | `@output/template-delete` |
| `gc` | `{ total, reachable, collected, scanned }` | `@output/gc` |
| Command | Description |
|---------|-------------|
| `init` | Create store directory and write bootstrap seed; prints meta hash |
| `bootstrap` | Write meta-schema seed into existing store; prints hash |
| `schema put <file.json>` | Register schema from file; prints type hash |
| `schema get <type-hash>` | Print schema JSON |
| `schema list` | List all schemas (`hash name`) |
| `schema validate <hash>` | Validate node against its schema; prints `valid` / `invalid` |
| `put <type-hash> <file.json>` | Store node; prints content hash |
| `get <hash>` | Print full node as JSON |
| `has <hash>` | Print `true` or `false` |
| `verify <hash>` | Verify integrity; prints `ok` or `corrupted` |
| `refs <hash>` | Print direct `cas_ref` targets (one per line) |
| `walk <hash>` | BFS traversal; one hash per line |
| `walk <hash> --format tree` | Tree-formatted traversal |
| `hash <type-hash> <file.json>` | Compute hash without storing |
| `cat <hash>` | Print node JSON |
| `cat <hash> --payload` | Print payload only |
### Examples
```bash
# Register a schema (schemas are plain @schema nodes) and store a payload
json-cas put @schema ./schemas/item.json
# → { "type": "...", "value": "0123456789ABC" } (the schema's type hash)
# Initialize default store at ~/.uncaged/json-cas
json-cas init
json-cas put 0123456789ABC ./payloads/item.json
# → { "type": "...", "value": "<content-hash>" }
# Use a custom store path
json-cas --store ./data/cas bootstrap
# Register a schema and store a payload
json-cas schema put ./schemas/item.json
# → prints type hash, e.g. 0123456789ABCD
json-cas put 0123456789ABCD ./payloads/item.json
# → prints content hash
json-cas get <content-hash> --json
json-cas verify <content-hash>
json-cas walk <content-hash> --format tree
# List every registered schema, then extract the hashes with jq
json-cas list --type @schema | jq -r '.value[]'
```
### Pipe composition
Because every command shares the `{ type, value }` envelope, output composes directly into
`render -p`:
```bash
# put emits a cas_ref hash envelope; render -p dereferences and renders the node
json-cas put @schema ./schemas/item.json | json-cas render -p
# render gc statistics
json-cas gc | json-cas render -p
# render every schema referenced by a list result
json-cas list --type @schema | json-cas render -p
```
### Templates
`template` commands manage the LiquidJS template bound to a schema (stored as a
`@ucas/template/text/<schema-hash>` variable). `render <hash>` uses the template registered
for the node's type, falling back to YAML when none exists.
```bash
# Bind a template to a schema, then render a node of that type
json-cas template set 0123456789ABC --inline "Item: {{ payload.name }}"
json-cas render <content-hash>
# → Item: Widget
```
## Internal Structure
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-json-cas",
"version": "0.6.0",
"version": "0.5.3",
"type": "module",
"bin": {
"json-cas": "./src/index.ts",
@@ -8,10 +8,10 @@
},
"scripts": {
"test": "bun test",
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"@uncaged/json-cas": "^0.6.0",
"@uncaged/json-cas-fs": "^0.6.0"
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3"
}
}
@@ -1,289 +0,0 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"type": "2NHHHE9NR0FVT",
"value": {
"payload": {
"age": 30,
"name": "Alice",
},
"type": "3K9ETAPGFPE32",
},
}
`;
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
{
"type": "BZ3TV0PTATA52",
"value": "ok",
}
`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
"{
"type": "18HZGJY02WN1K",
"value": []
}"
`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
"{
"type": "9BVX9SHACFC5J",
"value": [
"A0QKG4ERMXSFG"
]
}"
`;
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": "EMX6HW4CECBGK",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "3K9ETAPGFPE32",
"tags": {},
"value": "A0QKG4ERMXSFG",
},
}
`;
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "F68GWJAT9H6HP",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "3K9ETAPGFPE32",
"tags": {},
"value": "A0QKG4ERMXSFG",
},
}
`;
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
{
"type": "A4TV1FVBBNG5M",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "3K9ETAPGFPE32",
"tags": {},
"value": "A0QKG4ERMXSFG",
},
],
}
`;
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
{
"type": "A4TV1FVBBNG5M",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "3K9ETAPGFPE32",
"tags": {},
"value": "A0QKG4ERMXSFG",
},
],
}
`;
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
{
"type": "EMX6HW4CECBGK",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "3K9ETAPGFPE32",
"tags": {},
"value": "FNDM9C6P23238",
},
}
`;
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
{
"type": "92JS8RXGKDKC2",
"value": {
"labels": [
"important",
],
"name": "myapp/config",
"schema": "3K9ETAPGFPE32",
"tags": {
"env": "prod",
},
"value": "A0QKG4ERMXSFG",
},
}
`;
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "A4TV1FVBBNG5M",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "3K9ETAPGFPE32",
"tags": {
"env": "prod",
},
"value": "A0QKG4ERMXSFG",
},
],
}
`;
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
{
"type": "A4TV1FVBBNG5M",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "3K9ETAPGFPE32",
"tags": {
"env": "prod",
},
"value": "A0QKG4ERMXSFG",
},
],
}
`;
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
{
"type": "92JS8RXGKDKC2",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "3K9ETAPGFPE32",
"tags": {
"env": "prod",
},
"value": "A0QKG4ERMXSFG",
},
}
`;
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
{
"type": "00YH4RTNVWTRB",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "3K9ETAPGFPE32",
"tags": {
"env": "prod",
},
"value": "A0QKG4ERMXSFG",
},
],
}
`;
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=3K9ETAPGFPE32"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
{
"type": "DTNQZ90QQHA6D",
"value": {
"contentHash": "DBBPX5JJ74SWZ",
"schemaHash": "3K9ETAPGFPE32",
},
}
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
{
"type": "8KMGVC6TG12TS",
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
}
`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
{
"type": "6RZCT3XX4J8BC",
"value": [
{
"contentHash": "DBBPX5JJ74SWZ",
"schemaHash": "3K9ETAPGFPE32",
},
],
}
`;
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
{
"type": "DBHJH385HGVTM",
"value": {
"deleted": true,
},
}
`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 3K9ETAPGFPE32"`;
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: json-cas var set <name> <hash> [--tag <tag>...]"`;
exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`;
exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = `
"Usage: json-cas [--store <path>] [--json] <command> [args]
All JSON commands emit a { type, value } envelope. The type is the hash of the
command's @output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (cas_ref hashes are expanded).
Commands:
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@output/var-tag)
template set <schema-hash> <file> | --inline <text> Set template for schema (@output/template-set)
template get <schema-hash> Get template content (value=string) (@output/template-get)
template list List all templates (@output/template-list)
template delete <schema-hash> Delete template for schema (@output/template-delete)
gc Run garbage collection (@output/gc)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)"
`;
File diff suppressed because it is too large Load Diff
-695
View File
@@ -1,695 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const entrypoint = resolve(import.meta.dir, "index.ts");
let tmpStore: string;
let varDbPath: string;
// Shared hashes across phases
let typeHash: string;
let nodeHash: string;
beforeAll(() => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
/**
* Parse JSON and strip volatile fields (timestamp, created, updated)
* so snapshots are stable across runs.
*/
function stripVolatile(json: string): unknown {
const strip = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(strip);
if (v !== null && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (k === "timestamp" || k === "created" || k === "updated") continue;
out[k] = strip(val);
}
return out;
}
return v;
};
return strip(JSON.parse(json));
}
/** 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 init + put with @object bootstraps store", async () => {
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
additionalProperties: false,
}),
);
// 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 () => {
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout, exitCode } = await runCli(["put", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
nodeHash = envValue(stdout) as string;
});
test("1.6 get returns node JSON (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["get", nodeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.7 has returns true for existing node", async () => {
const { stdout, exitCode } = await runCli(["has", nodeHash]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe(true);
});
test("1.8 has returns false for non-existing hash", async () => {
const { stdout, exitCode } = await runCli(["has", "AAAAAAAAAAAAA"]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe(false);
});
test("1.9 verify returns ok for valid node", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.10 refs lists direct references (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["refs", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.11 walk shows traversal tree (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["walk", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.12 hash dry-run returns same hash as put", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stdout, exitCode } = await runCli(["hash", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe(nodeHash);
});
test("1.13 list --type returns nodes of that type", async () => {
const { stdout, exitCode } = await runCli(["list", "--type", typeHash]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toContain(nodeHash);
});
});
// ---- Phase 2: Schema Validation ----
describe("Phase 2: Schema Validation", () => {
test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => {
const badFile = join(tmpStore, "bad-node.json");
writeFileSync(badFile, JSON.stringify({ name: 123 }));
const { stdout, stderr, exitCode } = await runCli([
"put",
typeHash,
badFile,
]);
expect(exitCode).not.toBe(0);
expect(stdout).toBe("");
expect(stderr).toContain("Validation failed");
expect(stderr).toContain(typeHash);
// Do NOT snapshot stderr — it embeds a machine-specific tmp path
});
test("2.2 verify on valid node returns ok (hash + schema)", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe("ok");
});
test("2.3 put against non-existent schema hash fails", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stderr, exitCode } = await runCli([
"put",
"AAAAAAAAAAAAA",
nodeFile,
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 3: Variable System ----
describe("Phase 3: Variable System", () => {
test("3.1 var set creates variable", async () => {
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
nodeHash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.2 var get returns variable", async () => {
const { stdout, exitCode } = await runCli([
"var",
"get",
"myapp/config",
"--schema",
typeHash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain(nodeHash);
});
test("3.3 var list shows all variables", async () => {
const { stdout, exitCode } = await runCli(["var", "list"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain("myapp/config");
});
test("3.4 var list prefix filters by prefix", async () => {
const { stdout, exitCode } = await runCli(["var", "list", "myapp/"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain("myapp/config");
});
test("3.5 var set upsert updates existing variable", async () => {
const node2File = join(tmpStore, "node2.json");
writeFileSync(node2File, JSON.stringify({ name: "Bob", age: 25 }));
const { stdout: node2Out } = await runCli(["put", typeHash, node2File]);
const node2Hash = envValue(node2Out) as string;
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
node2Hash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
// Restore original value
await runCli(["var", "set", "myapp/config", nodeHash]);
});
test("3.6 var tag adds kv tag and label", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"myapp/config",
"--schema",
typeHash,
"env:prod",
"important",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.7 var list --tag env:prod filters by kv tag", async () => {
const { stdout, exitCode } = await runCli([
"var",
"list",
"--tag",
"env:prod",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("myapp/config");
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.8 var list --tag important filters by label", async () => {
const { stdout, exitCode } = await runCli([
"var",
"list",
"--tag",
"important",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("myapp/config");
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.9 var tag remove deletes label", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"myapp/config",
"--schema",
typeHash,
":important",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
// Verify label is gone
const { stdout: listOut } = await runCli([
"var",
"list",
"--tag",
"important",
]);
expect(listOut).not.toContain("myapp/config");
});
test("3.10 var delete removes variable", async () => {
const { exitCode, stdout } = await runCli([
"var",
"delete",
"myapp/config",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.11 var get deleted variable returns not found", async () => {
const { stderr, exitCode } = await runCli([
"var",
"get",
"myapp/config",
"--schema",
typeHash,
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 4: Template System ----
describe("Phase 4: Template System", () => {
test("4.1 template set registers template", async () => {
const tmplFile = join(tmpStore, "test.liquid");
writeFileSync(tmplFile, "Name: {{ payload.name }}, Age: {{ payload.age }}");
const { exitCode, stdout } = await runCli([
"template",
"set",
typeHash,
tmplFile,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.2 template get returns template text", async () => {
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe(
"Name: {{ payload.name }}, Age: {{ payload.age }}",
);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.3 template list shows registered templates", async () => {
const { stdout, exitCode } = await runCli(["template", "list"]);
expect(exitCode).toBe(0);
expect(stdout).toContain(typeHash);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.4 template delete removes template", async () => {
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.5 template get deleted template returns not found", async () => {
const { stderr, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 5: Render ----
describe("Phase 5: Render", () => {
beforeAll(async () => {
const tmplFile = join(tmpStore, "render-template.liquid");
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
await runCli(["template", "set", typeHash, tmplFile]);
});
test("5.1 render fills payload variables", async () => {
const { stdout, exitCode } = await runCli(["render", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Hello Alice!");
expect(stdout).toMatchSnapshot();
});
test("5.2 render --resolution with different value", async () => {
const { stdout, exitCode } = await runCli([
"render",
nodeHash,
"--resolution",
"0.5",
]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("5.3 render non-existent hash fails with error", async () => {
const { stderr, exitCode } = await runCli(["render", "ZZZZZZZZZZZZZ"]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Node not found");
expect(stderr).toContain("ZZZZZZZZZZZZZ");
});
});
// ---- Phase 6: GC ----
describe("Phase 6: GC", () => {
let gcNodeHash: string;
beforeAll(async () => {
// Create a fresh node for GC tests (independent of shared nodeHash)
const gcNodeFile = join(tmpStore, "gc-node.json");
writeFileSync(gcNodeFile, JSON.stringify({ name: "GcAlice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, gcNodeFile]);
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]);
});
test("6.1 gc runs without error", async () => {
const { exitCode, stdout } = await runCli(["gc"]);
expect(exitCode).toBe(0);
// Assert structural shape only — exact counts depend on phase history
const result = envValue(stdout) as Record<string, unknown>;
expect(typeof result.total).toBe("number");
expect(typeof result.reachable).toBe("number");
expect(typeof result.collected).toBe("number");
expect(typeof result.scanned).toBe("number");
expect(result.total as number).toBeGreaterThanOrEqual(
result.reachable as number,
);
});
test("6.2 gc preserves node referenced by a var", async () => {
const { exitCode } = await runCli(["gc"]);
expect(exitCode).toBe(0);
const { stdout } = await runCli(["has", gcNodeHash]);
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: orphanOut } = await runCli(["put", typeHash, orphanFile]);
const orphanHash = envValue(orphanOut) as string;
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
expect(envValue(beforeGc)).toBe(true);
await runCli(["gc"]);
const { stdout: afterGc } = await runCli(["has", orphanHash]);
expect(envValue(afterGc)).toBe(false);
});
});
// ---- Phase 7: Edge Cases ----
describe("Phase 7: Edge Cases", () => {
test("7.1 get non-existent hash errors gracefully", async () => {
const { stderr, exitCode } = await runCli(["get", "AAAAAAAAAAAAA"]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
test("7.2 put with non-existent file errors with ENOENT", async () => {
const { stderr, exitCode } = await runCli([
"put",
typeHash,
"/nonexistent/file.json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("ENOENT");
});
test("7.3 var set empty name errors", async () => {
const { stderr, exitCode } = await runCli(["var", "set", "", nodeHash]);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
expect(stderr).toMatchSnapshot();
});
test("7.4 var set name with invalid chars errors", async () => {
const { stderr, exitCode } = await runCli([
"var",
"set",
"invalid name!",
nodeHash,
]);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
expect(stderr).toMatchSnapshot();
});
test("7.5 no subcommand shows help text", async () => {
const { stdout, stderr, exitCode: _exitCode } = await runCli([]);
const combined = stdout + stderr;
expect(combined.length).toBeGreaterThan(0);
expect(combined).toMatchSnapshot();
expect(combined.toLowerCase()).toContain("usage");
});
test("7.6 --store path is a file errors", async () => {
const fileAsStore = join(tmpStore, "not-a-directory");
writeFileSync(fileAsStore, "test");
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
fileAsStore,
"--var-db",
varDbPath,
"get",
"AAAAAAAAAAAAA",
],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stderr = (await new Response(proc.stderr).text()).trim();
expect(exitCode).not.toBe(0);
expect(stderr).toContain("not a directory");
});
});
// ---- 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)");
});
});
// ---- Phase 9: Put/Hash Pipe Input ----
describe("Phase 9: Put/Hash Pipe Input", () => {
test("9.1 put -p reads JSON from stdin and stores node", async () => {
const payload = JSON.stringify({ name: "PipeAlice", age: 99 });
const { stdout, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
payload,
);
expect(exitCode).toBe(0);
const hash = envValue(stdout);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Verify stored correctly
const { stdout: getOut } = await runCli(["get", hash as string]);
expect(getOut).toContain("PipeAlice");
});
test("9.2 hash -p reads JSON from stdin and computes hash without storing", async () => {
const payload = JSON.stringify({ name: "PipeBob", age: 55 });
const { stdout, exitCode } = await runCliWithStdin(
["hash", typeHash, "-p"],
payload,
);
expect(exitCode).toBe(0);
const hash = envValue(stdout);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Should NOT be stored
const { exitCode: hasExit, stdout: hasOut } = await runCli([
"has",
hash as string,
]);
expect(hasExit).toBe(0);
expect(envValue(hasOut)).toBe(false);
});
test("9.3 put -p with file arg errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "some-file.json", "-p"],
"{}",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Cannot use --pipe/-p with a file argument");
});
test("9.4 put -p with empty stdin errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
"",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("No input on stdin");
});
test("9.5 put -p with invalid JSON errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
"not json",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Invalid JSON on stdin");
});
});
+285 -189
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bun
import { existsSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { Hash, Store, VariableStore } from "@uncaged/json-cas";
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
import {
bootstrap,
CasNodeNotFoundError,
@@ -22,9 +22,8 @@ import {
validate,
verify,
walk,
wrapEnvelope,
} from "@uncaged/json-cas";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Argument parsing ----
@@ -41,7 +40,6 @@ const VALUE_FLAGS = new Set([
"decay",
"epsilon",
"inline",
"type",
]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
@@ -75,8 +73,6 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
} else {
flags[key] = true;
}
} else if (arg === "-p") {
flags.p = true;
} else {
positional.push(arg);
}
@@ -115,33 +111,27 @@ function readJsonFile(file: string): unknown {
}
}
async function readStdinJson(): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const input = Buffer.concat(chunks).toString("utf-8").trim();
if (!input) {
die("No input on stdin. Pipe JSON content.");
}
try {
return JSON.parse(input);
} catch {
return die("Invalid JSON on stdin.");
}
}
/**
* Open the filesystem-backed CAS store.
* Automatically creates directory and bootstraps if needed.
* Validate that the store directory exists.
* Dies with a clear error message if not found.
*/
async function openStore(): Promise<Store> {
const fullPath = resolve(storePath);
return await openFsStore(fullPath);
function validateStoreExists(path: string): void {
if (!existsSync(path)) {
die(`Store not found at ${path}`);
}
}
async function openVarStore(): Promise<VariableStore> {
const store = await openStore();
function openStore(shouldCreate = false): Store {
const fullPath = resolve(storePath);
if (!shouldCreate) {
validateStoreExists(fullPath);
}
return createFsStore(fullPath);
}
function openVarStore(): VariableStore {
const store = openStore(true);
mkdirSync(resolve(storePath), { recursive: true });
return createVariableStore(resolve(varDbPath), store);
}
@@ -150,9 +140,12 @@ async function openVarStore(): Promise<VariableStore> {
* If the input starts with @, resolve it via bootstrap
* Otherwise, return the hash as-is
*/
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
async function resolveTypeHash(
typeHashOrAlias: string,
shouldCreate = false,
): Promise<Hash> {
if (typeHashOrAlias.startsWith("@")) {
const store = await openStore();
const store = openStore(shouldCreate);
const builtinSchemas = await bootstrap(store);
const resolvedHash = builtinSchemas[typeHashOrAlias];
if (!resolvedHash) {
@@ -163,6 +156,55 @@ async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
return typeHashOrAlias;
}
/**
* Get the Variable schema's CAS hash
* This is the type hash used in JSON envelopes
*/
async function getVariableSchemaHash(): Promise<Hash> {
const store = openStore();
// Define the Variable JSON Schema (updated for new model with composite key)
const variableSchema: JSONSchema = {
title: "Variable",
type: "object",
properties: {
name: { type: "string" },
schema: { type: "string" },
value: { type: "string" },
created: { type: "number" },
updated: { type: "number" },
tags: { type: "object" },
labels: { type: "array", items: { type: "string" } },
},
required: [
"name",
"schema",
"value",
"created",
"updated",
"tags",
"labels",
],
};
// Compute hash or retrieve from store
const hash = await putSchema(store, variableSchema);
return hash;
}
/**
* Wrap Variable output in JSON envelope
*/
async function wrapVariableEnvelope(
variable: unknown,
): Promise<{ type: Hash; value: unknown }> {
const typeHash = await getVariableSchemaHash();
return {
type: typeHash,
value: variable,
};
}
/**
* Parse tag/label arguments
* Returns: { tags: Record<string, string>, labels: string[], deleteNames: string[] }
@@ -197,103 +239,124 @@ function parseTagsLabels(args: string[]): {
// ---- Commands ----
async function cmdPut(args: string[]): Promise<void> {
const isPipe = flags.pipe === true || flags.p === true;
const typeHashOrAlias = args[0];
const file = isPipe ? undefined : args[1];
if (!typeHashOrAlias || (!isPipe && !file))
die(
"Usage: json-cas put <type-hash> <file.json>\n json-cas put <type-hash> --pipe/-p",
);
if (isPipe && args[1])
die("Cannot use --pipe/-p with a file argument. Use one or the other.");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = isPipe ? await readStdinJson() : readJsonFile(file as string);
const store = await openStore();
// Schema nodes: use putSchema() which validates via isValidSchema() (recursive)
// instead of ajv against meta-schema (which can't express recursive constraints)
async function cmdInit(): Promise<void> {
const dir = resolve(storePath);
mkdirSync(dir, { recursive: true });
const store = createFsStore(dir);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (typeHash === metaHash) {
try {
const hash = await putSchema(store, payload as Record<string, unknown>);
out(await wrapEnvelope(store, "@output/put", hash));
} catch (e) {
console.error(
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
);
process.exit(1);
console.log(metaHash);
}
async function cmdBootstrap(): Promise<void> {
const store = openStore(true);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
console.log(metaHash);
}
async function cmdSchemaPut(args: string[]): Promise<void> {
const file = args[0];
if (!file) die("Usage: json-cas schema put <file.json>");
const schema = readJsonFile(file) as JSONSchema;
const store = openStore(true);
const hash = await putSchema(store, schema);
console.log(hash);
}
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}`);
}
return;
}
}
// Check if schema exists
const schema = getSchema(store, typeHash);
if (schema === null) {
console.error(`Schema not found: ${typeHash}`);
process.exit(1);
}
// Validate payload against schema before storing
const tempNode = { type: typeHash, payload, timestamp: Date.now() };
if (!validate(store, tempNode)) {
console.error(
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
);
process.exit(1);
}
async function cmdSchemaValidate(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas schema validate <hash>");
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const valid = validate(store, node);
console.log(valid ? "valid" : "invalid");
}
async function cmdPut(args: string[]): Promise<void> {
const typeHashOrAlias = args[0];
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas put <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
const payload = readJsonFile(file);
const store = openStore(true);
const hash = await store.put(typeHash, payload);
out(await wrapEnvelope(store, "@output/put", hash));
console.log(hash);
}
async function cmdGet(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas get <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
out(await wrapEnvelope(store, "@output/get", node));
out(node);
}
async function cmdHas(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas has <hash>");
const store = await openStore();
out(await wrapEnvelope(store, "@output/has", store.has(hash)));
const store = openStore();
console.log(String(store.has(hash)));
}
async function cmdVerify(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas verify <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const ok = await verify(hash, node);
let status: string;
if (!ok) {
status = "corrupted";
} else {
status = validate(store, node) ? "ok" : "invalid";
}
out(await wrapEnvelope(store, "@output/verify", status));
console.log(ok ? "ok" : "corrupted");
}
async function cmdRefs(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas refs <hash>");
const store = await openStore();
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const refHashes = refs(store, node);
out(await wrapEnvelope(store, "@output/refs", refHashes));
for (const r of refHashes) {
console.log(r);
}
}
async function cmdWalk(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas walk <hash> [--format tree]");
const store = await openStore();
const store = openStore();
const format = flags.format;
if (format === "tree") {
@@ -303,16 +366,15 @@ async function cmdWalk(args: string[]): Promise<void> {
});
const printed = new Set<Hash>();
const lines: string[] = [];
function printNode(h: Hash, prefix: string, isLast: boolean): void {
const connector = prefix === "" ? "" : isLast ? "└── " : "├── ";
if (printed.has(h)) {
lines.push(`${prefix}${connector}${h} (seen)`);
console.log(`${prefix}${connector}${h} (seen)`);
return;
}
printed.add(h);
lines.push(`${prefix}${connector}${h}`);
console.log(`${prefix}${connector}${h}`);
const kids = childMap.get(h) ?? [];
const childPrefix =
@@ -323,31 +385,22 @@ async function cmdWalk(args: string[]): Promise<void> {
}
printNode(hash, "", true);
out(await wrapEnvelope(store, "@output/walk", lines.join("\n")));
} else {
const hashes: Hash[] = [];
walk(store, hash, (h) => {
hashes.push(h);
console.log(h);
});
out(await wrapEnvelope(store, "@output/walk", hashes));
}
}
async function cmdHash(args: string[]): Promise<void> {
const isPipe = flags.pipe === true || flags.p === true;
const typeHashOrAlias = args[0];
const file = isPipe ? undefined : args[1];
if (!typeHashOrAlias || (!isPipe && !file))
die(
"Usage: json-cas hash <type-hash> <file.json>\n json-cas hash <type-hash> --pipe/-p",
);
if (isPipe && args[1])
die("Cannot use --pipe/-p with a file argument. Use one or the other.");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = isPipe ? await readStdinJson() : readJsonFile(file as string);
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas hash <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
const payload = readJsonFile(file);
const hash = await computeHash(typeHash, payload);
const store = await openStore();
out(await wrapEnvelope(store, "@output/hash", hash));
console.log(hash);
}
async function cmdRender(args: string[]): Promise<void> {
@@ -364,7 +417,7 @@ async function cmdRender(args: string[]): Promise<void> {
);
}
const store = await openStore();
const store = openStore();
// Parse numeric options
const resolution =
@@ -439,7 +492,7 @@ async function cmdRender(args: string[]): Promise<void> {
);
process.stdout.write(output);
} else {
const varStore = await openVarStore();
const varStore = openVarStore();
const output = await renderAsync(store, hash, {
resolution,
decay,
@@ -450,9 +503,6 @@ async function cmdRender(args: string[]): Promise<void> {
process.stdout.write(output);
}
} catch (error) {
if (error instanceof CasNodeNotFoundError) {
die(`Error: Node not found: ${error.hash}`);
}
if (error instanceof Error) {
die(error.message);
}
@@ -460,6 +510,19 @@ async function cmdRender(args: string[]): Promise<void> {
}
}
async function cmdCat(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas cat <hash>");
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
if (flags.payload === true) {
out(node.payload);
} else {
out(node);
}
}
async function cmdVarSet(args: string[]): Promise<void> {
const name = args[0];
const value = args[1];
@@ -469,8 +532,7 @@ async function cmdVarSet(args: string[]): Promise<void> {
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
}
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
@@ -497,7 +559,8 @@ async function cmdVarSet(args: string[]): Promise<void> {
: undefined;
const variable = varStore.set(name, value, options);
out(await wrapEnvelope(store, "@output/var-set", variable));
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof InvalidVariableNameError ||
@@ -520,15 +583,15 @@ async function cmdVarGet(args: string[]): Promise<void> {
die("Usage: json-cas var get <name> --schema <hash>");
}
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
const varStore = openVarStore();
try {
const variable = varStore.get(name, schema);
if (variable === null) {
die(`Error: Variable not found: name=${name}, schema=${schema}`);
}
out(await wrapEnvelope(store, "@output/var-get", variable));
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} finally {
varStore.close();
}
@@ -542,18 +605,19 @@ async function cmdVarDelete(args: string[]): Promise<void> {
die("Usage: json-cas var delete <name> [--schema <hash>]");
}
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
const varStore = openVarStore();
try {
if (schema !== undefined) {
// Precise deletion: remove specific (name, schema) variant
const variable = varStore.remove(name, schema);
out(await wrapEnvelope(store, "@output/var-delete", variable));
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} else {
// Batch deletion: remove all variants for this name
const variables = varStore.remove(name);
out(await wrapEnvelope(store, "@output/var-delete", variables));
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
}
} catch (e) {
if (e instanceof VariableNotFoundError) {
@@ -578,8 +642,7 @@ async function cmdVarTag(args: string[]): Promise<void> {
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
const varStore = openVarStore();
try {
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
@@ -590,7 +653,8 @@ async function cmdVarTag(args: string[]): Promise<void> {
delete: deleteNames.length > 0 ? deleteNames : undefined,
});
out(await wrapEnvelope(store, "@output/var-tag", variable));
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
@@ -610,8 +674,7 @@ async function cmdVarList(args: string[]): Promise<void> {
const schema = flags.schema as string | undefined;
const tagFlags = flags.tag;
const store = await openStore();
const varStore = createVariableStore(resolve(varDbPath), store);
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
@@ -633,7 +696,8 @@ async function cmdVarList(args: string[]): Promise<void> {
tags: Object.keys(tags).length > 0 ? tags : undefined,
labels: labels.length > 0 ? labels : undefined,
});
out(await wrapEnvelope(store, "@output/var-list", variables));
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
} catch (e) {
if (e instanceof InvalidVariableNameError) {
die(`Error: ${e.message}`);
@@ -652,7 +716,8 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
}
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -702,12 +767,10 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
const varName = `@ucas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
out(
await wrapEnvelope(store, "@output/template-set", {
schemaHash,
contentHash,
}),
);
out({
schemaHash,
contentHash,
});
} catch (e) {
if (e instanceof CasNodeNotFoundError) {
die(`Error: ${e.message}`);
@@ -725,7 +788,8 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
die("Usage: json-cas template get <schema-hash>");
}
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -743,16 +807,16 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
die(`Error: Content not found in CAS: ${variable.value}`);
}
out(
await wrapEnvelope(store, "@output/template-get", node.payload as string),
);
// Output raw text (not JSON)
process.stdout.write(node.payload as string);
} finally {
varStore.close();
}
}
async function cmdTemplateList(_args: string[]): Promise<void> {
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -762,12 +826,24 @@ async function cmdTemplateList(_args: string[]): Promise<void> {
schema: stringHash,
});
const templates = variables.map((v) => ({
schemaHash: v.name.replace("@ucas/template/text/", ""),
contentHash: v.value,
}));
const templates = variables.map((v) => {
const schemaHash = v.name.replace("@ucas/template/text/", "");
out(await wrapEnvelope(store, "@output/template-list", templates));
// Get content for preview
const node = store.get(v.value);
const content = (node?.payload as string | undefined) ?? "";
// Truncate preview to 80 chars
const preview =
content.length > 80 ? `${content.slice(0, 77)}...` : content;
return {
schemaHash,
preview,
};
});
out(templates);
} finally {
varStore.close();
}
@@ -780,7 +856,8 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
die("Usage: json-cas template delete <schema-hash>");
}
const store = await openStore();
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
@@ -788,9 +865,7 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
const stringHash = await resolveTypeHash("@string");
varStore.remove(varName, stringHash);
out(
await wrapEnvelope(store, "@output/template-delete", { deleted: true }),
);
out({ deleted: true });
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: Template not found for schema: ${schemaHash}`);
@@ -802,56 +877,48 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
}
async function cmdGc(_args: string[]): Promise<void> {
const store = await openStore();
const store = createFsStore(storePath);
const varStore = createVariableStore(varDbPath, store);
try {
const stats = gc(store, varStore);
out(await wrapEnvelope(store, "@output/gc", stats));
out(stats);
} finally {
varStore.close();
}
}
async function cmdList(_args: string[]): Promise<void> {
const typeFlag = flags.type;
if (typeof typeFlag !== "string")
die("Usage: json-cas list --type <hash-or-alias>");
const typeHash = await resolveTypeHash(typeFlag);
const store = await openStore();
const hashes = Array.from(store.listByType(typeHash));
out(await wrapEnvelope(store, "@output/list", hashes));
}
function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
All JSON commands emit a { type, value } envelope. The type is the hash of the
command's @output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (cas_ref hashes are expanded).
Commands:
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@output/var-tag)
template set <schema-hash> <file> | --inline <text> Set template for schema (@output/template-set)
template get <schema-hash> Get template content (value=string) (@output/template-get)
template list List all templates (@output/template-list)
template delete <schema-hash> Delete template for schema (@output/template-delete)
gc Run garbage collection (@output/gc)
init Create store dir and write bootstrap seed
bootstrap Write meta-schema seed, print hash
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
schema validate <hash> Validate node against its schema
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
render <hash> [options] Render node as YAML with resolution decay
render --pipe/-p [options] Render { type, value } from stdin
cat <hash> [--payload] Output node (--payload for payload only)
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
var delete <name> [--schema <hash>] Delete variable(s)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables
var tag <name> --schema <hash> <operations...> Modify tags/labels
template set <schema-hash> <file> | --inline <text> Set template for schema
template get <schema-hash> Get template content as raw text
template list List all templates
template delete <schema-hash> Delete template for schema
gc Run garbage collection
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
@@ -863,7 +930,7 @@ Flags:
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)`);
--pipe, -p Read { type, value } JSON from stdin for render`);
}
// ---- Dispatch ----
@@ -876,6 +943,35 @@ if (!cmd) {
}
switch (cmd) {
case "init":
await cmdInit();
break;
case "bootstrap":
await cmdBootstrap();
break;
case "schema": {
const [sub, ...subRest] = rest;
switch (sub) {
case "put":
await cmdSchemaPut(subRest);
break;
case "get":
await cmdSchemaGet(subRest);
break;
case "list":
await cmdSchemaList();
break;
case "validate":
await cmdSchemaValidate(subRest);
break;
default:
die(`Unknown schema subcommand: ${sub ?? "(none)"}`);
}
break;
}
case "put":
await cmdPut(rest);
break;
@@ -908,8 +1004,8 @@ switch (cmd) {
await cmdRender(rest);
break;
case "list":
await cmdList(rest);
case "cat":
await cmdCat(rest);
break;
case "var": {
+71 -62
View File
@@ -103,10 +103,9 @@ describe("template set", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("contentHash");
expect(envelope.value.schemaHash).toBe(stringHash);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
});
test("set template with --inline flag", async () => {
@@ -123,10 +122,9 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("contentHash");
expect(envelope.value.schemaHash).toBe(stringHash);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
});
test("update existing template (idempotent)", async () => {
@@ -150,12 +148,12 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value).toHaveProperty("contentHash");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
// Verify we can get the new version
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(JSON.parse(getOut).value).toBe("Version 2");
expect(getOut).toBe("Version 2");
});
test("error when file not found", async () => {
@@ -225,7 +223,7 @@ describe("template set", () => {
// Verify content
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(JSON.parse(getOut).value).toBe(multilineContent);
expect(getOut).toBe(multilineContent);
});
test("support empty templates", async () => {
@@ -242,8 +240,8 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value).toHaveProperty("contentHash");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
});
test("error when neither file nor --inline provided", async () => {
@@ -273,12 +271,12 @@ describe("template set", () => {
// Verify content preserved
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(JSON.parse(getOut).value).toBe(specialContent);
expect(getOut).toBe(specialContent);
});
});
describe("template get", () => {
test("retrieve template as envelope value", async () => {
test("retrieve template as raw text", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
@@ -293,9 +291,7 @@ describe("template get", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toBe(content);
expect(stdout).toBe(content);
});
test("error when template not found", async () => {
@@ -313,26 +309,27 @@ describe("template get", () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// The envelope's value preserves exact whitespace (JSON-escaped),
// so trimming the surrounding JSON output is harmless.
// Note: runCli helper trims stdout, so we test with content that doesn't have leading/trailing whitespace
// The actual CLI preserves whitespace correctly
const content = "spaces\n\ttabs\t\nmixed";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout } = await runCli("template", "get", stringHash);
expect(JSON.parse(stdout).value).toBe(content);
expect(stdout).toBe(content);
});
test("support multi-line templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Note: runCli helper trims stdout, so trailing newline will be removed
const multiline = "Line 1\nLine 2\nLine 3";
await runCli("template", "set", stringHash, "--inline", multiline);
const { stdout } = await runCli("template", "get", stringHash);
expect(JSON.parse(stdout).value).toBe(multiline);
expect(stdout).toBe(multiline);
});
});
@@ -349,40 +346,34 @@ describe("template list", () => {
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(Array.isArray(envelope.value)).toBe(true);
expect(envelope.value.length).toBeGreaterThanOrEqual(1);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBeGreaterThanOrEqual(1);
// Check structure
const item = envelope.value[0];
const item = output[0];
expect(item).toHaveProperty("schemaHash");
expect(item).toHaveProperty("contentHash");
expect(item).toHaveProperty("preview");
});
test("entry contentHash matches set result", async () => {
test("preview truncation for long content", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stdout: setOut } = await runCli(
"template",
"set",
stringHash,
"--inline",
"Some template content",
);
const { contentHash } = JSON.parse(setOut).value;
const longContent = "a".repeat(200);
await runCli("template", "set", stringHash, "--inline", longContent);
const { stdout } = await runCli("template", "list");
const value = JSON.parse(stdout).value as Array<{
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
contentHash: string;
preview: string;
}>;
const item = value.find((i) => i.schemaHash === stringHash);
const item = output.find((i) => i.schemaHash === stringHash);
expect(item).toBeDefined();
if (item) {
expect(item.contentHash).toBe(contentHash);
expect(item.preview.length).toBeLessThan(longContent.length);
expect(item.preview).toContain("...");
}
});
@@ -391,9 +382,9 @@ describe("template list", () => {
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(Array.isArray(envelope.value)).toBe(true);
expect(envelope.value.length).toBe(0);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBe(0);
});
test("exclude non-template variables", async () => {
@@ -409,14 +400,14 @@ describe("template list", () => {
const { stdout } = await runCli("template", "list");
const envelope = JSON.parse(stdout);
const output = JSON.parse(stdout);
// Should only contain template variables
for (const item of envelope.value) {
for (const item of output) {
expect(item.schemaHash).toBeDefined();
}
});
test("output JSON envelope with array value", async () => {
test("output JSON array format", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
@@ -427,8 +418,27 @@ describe("template list", () => {
// Should be valid JSON
expect(() => JSON.parse(stdout)).not.toThrow();
const envelope = JSON.parse(stdout);
expect(Array.isArray(envelope.value)).toBe(true);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
});
test("preview shows beginning of content", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const content = "Start of template...";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
preview: string;
}>;
const item = output.find((i) => i.schemaHash === stringHash);
if (item) {
expect(item.preview).toContain("Start");
}
});
});
@@ -448,10 +458,9 @@ describe("template delete", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("deleted");
expect(envelope.value.deleted).toBe(true);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("deleted");
expect(output.deleted).toBe(true);
// Verify template is gone
const { exitCode: getExitCode } = await runCli(
@@ -486,13 +495,13 @@ describe("template delete", () => {
// Verify second still exists
const { stdout } = await runCli("template", "list");
const value = JSON.parse(stdout).value as Array<{
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
contentHash: string;
preview: string;
}>;
// Should not find deleted template
const deleted = value.find((i) => i.schemaHash === stringHash);
const deleted = output.find((i) => i.schemaHash === stringHash);
expect(deleted).toBeUndefined();
});
@@ -510,7 +519,7 @@ describe("template delete", () => {
"--inline",
"Content",
);
const { contentHash } = JSON.parse(setOut).value;
const { contentHash } = JSON.parse(setOut);
// Delete the template variable
await runCli("template", "delete", stringHash);
@@ -568,7 +577,7 @@ describe("template integration", () => {
stringHash,
);
expect(getExit).toBe(0);
expect(JSON.parse(getOut).value).toBe(content);
expect(getOut).toBe(content);
// List
const { stdout: listOut, exitCode: listExit } = await runCli(
@@ -576,7 +585,7 @@ describe("template integration", () => {
"list",
);
expect(listExit).toBe(0);
const listData = JSON.parse(listOut).value;
const listData = JSON.parse(listOut);
expect(listData.length).toBeGreaterThan(0);
// Delete
@@ -617,8 +626,8 @@ describe("template integration", () => {
// List should show all
const { stdout } = await runCli("template", "list");
const value = JSON.parse(stdout).value;
expect(value.length).toBeGreaterThanOrEqual(1);
const output = JSON.parse(stdout);
expect(output.length).toBeGreaterThanOrEqual(1);
});
});
-27
View File
@@ -1,32 +1,5 @@
# @uncaged/json-cas-fs
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.6.0
## 0.5.3
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas-fs",
"version": "0.6.0",
"version": "0.5.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -16,10 +16,10 @@
],
"scripts": {
"test": "bun test",
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"@uncaged/json-cas": "^0.6.0",
"@uncaged/json-cas": "^0.5.3",
"cborg": "^4.2.3"
}
}
+1 -1
View File
@@ -1 +1 @@
export { createFsStore, openStore } from "./store.js";
export { createFsStore } from "./store.js";
+3 -120
View File
@@ -1,11 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
existsSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasNode } from "@uncaged/json-cas";
@@ -16,7 +10,7 @@ import {
verify,
} from "@uncaged/json-cas";
import { createFsStore, openStore } from "./store.js";
import { createFsStore } from "./store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "json-cas-fs-test-"));
@@ -65,7 +59,7 @@ describe("createFsStore – init and bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
});
});
@@ -318,114 +312,3 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// openStore – async with auto-bootstrap
// ──────────────────────────────────────────────────────────────────────────────
describe("openStore – async with auto-bootstrap", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("openStore returns Promise<Store>", async () => {
const store = await openStore(dir);
expect(store).toBeDefined();
expect(typeof store.put).toBe("function");
expect(typeof store.get).toBe("function");
});
test("openStore auto-creates directory when it doesn't exist", async () => {
const nested = join(dir, "sub", "nested", "store");
expect(existsSync(nested)).toBe(false);
const store = await openStore(nested);
expect(existsSync(nested)).toBe(true);
// Verify store works
const typeHash = await computeSelfHash({ name: "t" });
const hash = await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
});
test("openStore works when directory already exists", async () => {
// Pre-create the directory
const store1 = await openStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
await store1.put(typeHash, { x: 1 });
// Open again
const store2 = await openStore(dir);
expect(store2.listByType(typeHash)).toHaveLength(1);
});
test("openStore throws error when path exists but is not a directory", async () => {
const filePath = join(dir, "not-a-dir");
writeFileSync(filePath, "test");
await expect(openStore(filePath)).rejects.toThrow();
});
test("openStore auto-bootstraps on first open (empty directory)", async () => {
const store = await openStore(dir);
// Check that bootstrap schemas exist
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
expect(metaHash).toBeDefined();
expect(store.has(metaHash as string)).toBe(true);
// Verify all core schemas exist
expect(store.has(builtinSchemas["@string"] as string)).toBe(true);
expect(store.has(builtinSchemas["@number"] as string)).toBe(true);
expect(store.has(builtinSchemas["@object"] as string)).toBe(true);
expect(store.has(builtinSchemas["@array"] as string)).toBe(true);
expect(store.has(builtinSchemas["@bool"] as string)).toBe(true);
expect(store.has(builtinSchemas["@schema"] as string)).toBe(true);
});
test("openStore bootstrap is idempotent on subsequent opens", async () => {
const store1 = await openStore(dir);
const schemas1 = await bootstrap(store1);
const count1 = store1.listAll().length;
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
const count2 = store2.listAll().length;
// Same schemas, same count
expect(schemas1).toEqual(schemas2);
expect(count1).toBe(count2);
});
test("openStore works on already-bootstrapped store", async () => {
// Bootstrap manually first
const store1 = createFsStore(dir);
const schemas1 = await bootstrap(store1);
// Open with openStore
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
expect(schemas1).toEqual(schemas2);
});
test("openStore auto-bootstraps old store without bootstrap", async () => {
// Create a store with some data but no bootstrap
const store1 = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "custom" });
await store1.put(typeHash, { data: "old" });
// Open with openStore - should auto-bootstrap
const store2 = await openStore(dir);
const schemas = await bootstrap(store2);
expect(store2.has(schemas["@schema"] as string)).toBe(true);
// Old data still exists
expect(store2.listByType(typeHash)).toHaveLength(1);
});
});
-56
View File
@@ -5,7 +5,6 @@ import {
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
@@ -14,7 +13,6 @@ import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
bootstrap,
cborEncode,
computeHash,
computeSelfHash,
@@ -221,57 +219,3 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return store;
}
/**
* Open a filesystem-backed CAS store with automatic directory creation and bootstrap.
* This is an async function that:
* 1. Creates the directory (with recursive: true) if it doesn't exist
* 2. Validates that the path is actually a directory (not a file)
* 3. Creates the store
* 4. Runs bootstrap (which is idempotent)
*
* @param dir - The directory path for the store
* @returns A Promise resolving to the BootstrapCapableStore
* @throws Error if the path exists but is not a directory
*/
export async function openStore(dir: string): Promise<BootstrapCapableStore> {
// Create directory if it doesn't exist
try {
mkdirSync(dir, { recursive: true });
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "EACCES") {
throw new Error(`Permission denied: cannot access store at ${dir}`);
}
if (nodeError.code === "ENOTDIR") {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
}
throw error;
}
// Validate that the path is a directory
try {
const stats = statSync(dir);
if (!stats.isDirectory()) {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
throw new Error(`Store not found at ${dir}`);
}
}
throw error;
}
// Create the store
const store = createFsStore(dir);
// Bootstrap (idempotent)
await bootstrap(store);
return store;
}
-22
View File
@@ -1,27 +1,5 @@
# @uncaged/json-cas
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
## 0.5.3
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas",
"version": "0.6.0",
"version": "0.5.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -16,7 +16,7 @@
],
"scripts": {
"test": "bun test",
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"ajv": "^8.20.0",
+2 -191
View File
@@ -1,40 +1,18 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import type { JSONSchema } from "./schema.js";
import { getSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
const OUTPUT_ALIASES = [
"@output/put",
"@output/get",
"@output/has",
"@output/hash",
"@output/verify",
"@output/refs",
"@output/walk",
"@output/list",
"@output/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 24 built-in schema aliases to hashes", async () => {
test("should return map of built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
// Should return object with 6 primitive + 18 output aliases = 24
// Should return object with 6 aliases
expect(builtinSchemas).toHaveProperty("@schema");
expect(builtinSchemas).toHaveProperty("@string");
expect(builtinSchemas).toHaveProperty("@number");
@@ -42,12 +20,6 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty("@array");
expect(builtinSchemas).toHaveProperty("@bool");
for (const alias of OUTPUT_ALIASES) {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(24);
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
expect(typeof hash).toBe("string");
@@ -155,164 +127,3 @@ describe("bootstrap - Built-in Schemas", () => {
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// @output/* Schema Registration Tests
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - @output/* Schemas", () => {
test("each @output/* schema has a title", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
for (const alias of OUTPUT_ALIASES) {
const hash = aliases[alias];
if (!hash) throw new Error(`${alias} not found`);
const schema = getSchema(store, hash) as JSONSchema;
expect(schema).not.toBeNull();
expect(typeof schema.title).toBe("string");
expect((schema.title as string).startsWith("ucas ")).toBe(true);
}
});
test("@output/put schema describes a cas_ref string", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/put"];
if (!hash) throw new Error("@output/put not found");
const schema = getSchema(store, hash);
expect(schema).toEqual({
type: "string",
format: "cas_ref",
title: "ucas put result",
});
});
test("@output/get schema describes object with type, payload, timestamp", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/get"];
if (!hash) throw new Error("@output/get not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas get result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.type).toEqual({ type: "string", format: "cas_ref" });
expect(props.payload).toEqual({});
expect(props.timestamp).toEqual({ type: "number" });
});
test("@output/has schema describes a boolean", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/has"];
if (!hash) throw new Error("@output/has not found");
expect(getSchema(store, hash)).toEqual({
type: "boolean",
title: "ucas has result",
});
});
test("@output/verify schema describes enum of ok|corrupted|invalid", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/verify"];
if (!hash) throw new Error("@output/verify not found");
const schema = getSchema(store, hash);
expect(schema).toEqual({
type: "string",
enum: ["ok", "corrupted", "invalid"],
title: "ucas verify result",
});
});
test("@output/refs schema describes array of cas_ref strings", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/refs"];
if (!hash) throw new Error("@output/refs not found");
expect(getSchema(store, hash)).toEqual({
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas refs result",
});
});
test("@output/gc schema describes object with gc stats fields", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/gc"];
if (!hash) throw new Error("@output/gc not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas gc result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.total).toEqual({ type: "number" });
expect(props.reachable).toEqual({ type: "number" });
expect(props.collected).toEqual({ type: "number" });
expect(props.scanned).toEqual({ type: "number" });
});
test("@output/var-set schema describes a Variable object", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/var-set"];
if (!hash) throw new Error("@output/var-set not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas var set result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.name).toEqual({ type: "string" });
expect(props.schema).toEqual({ type: "string", format: "cas_ref" });
expect(props.value).toEqual({ type: "string", format: "cas_ref" });
});
test("@output/var-list schema describes array of Variable objects", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/var-list"];
if (!hash) throw new Error("@output/var-list not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("array");
expect(schema.title).toBe("ucas var list result");
const items = schema.items as JSONSchema;
expect(items.type).toBe("object");
const props = items.properties as Record<string, JSONSchema>;
expect(props.name).toEqual({ type: "string" });
});
test("@output/template-delete schema describes object with deleted boolean", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/template-delete"];
if (!hash) throw new Error("@output/template-delete not found");
expect(getSchema(store, hash)).toEqual({
type: "object",
properties: { deleted: { type: "boolean" } },
title: "ucas template delete result",
});
});
test("all @output/* schemas are distinct hashes", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const outputHashes = OUTPUT_ALIASES.map((alias) => aliases[alias]);
const uniqueHashes = new Set(outputHashes);
expect(uniqueHashes.size).toBe(OUTPUT_ALIASES.length);
});
});
+3 -179
View File
@@ -60,182 +60,12 @@ const BOOTSTRAP_PAYLOAD = {
enum: { type: "array" },
const: {},
description: { type: "string" },
// P1 leaf constraints
minimum: { type: "number" },
maximum: { type: "number" },
exclusiveMinimum: { type: "number" },
exclusiveMaximum: { type: "number" },
minLength: { type: "number" },
maxLength: { type: "number" },
pattern: { type: "string" },
minItems: { type: "number" },
maxItems: { type: "number" },
uniqueItems: { type: "boolean" },
},
} 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, 5 primitive schemas,
* and 18 @output/* schemas (24 total).
* The returned object contains aliases for the meta-schema and 5 primitive schemas.
* Idempotent: calling bootstrap multiple times returns the same hashes.
*/
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
@@ -253,8 +83,8 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
const arrayHash = await store.put(metaHash, { type: "array" });
const boolHash = await store.put(metaHash, { type: "boolean" });
// 3. Register @output/* schemas
const aliases: Record<string, Hash> = {
// 3. Return map of aliases to hashes
return {
"@schema": metaHash,
"@string": stringHash,
"@number": numberHash,
@@ -262,10 +92,4 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
"@array": arrayHash,
"@bool": boolHash,
};
for (const [alias, schema] of OUTPUT_SCHEMAS) {
aliases[alias] = await store.put(metaHash, schema);
}
return aliases;
}
+3 -5
View File
@@ -264,7 +264,7 @@ describe("bootstrap", () => {
);
});
test("returns a map with 24 built-in schema aliases", async () => {
test("returns a map with 6 built-in schema aliases", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
@@ -280,8 +280,6 @@ describe("bootstrap", () => {
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(Object.keys(builtinSchemas)).toHaveLength(24);
});
test("meta-schema node is stored and retrievable", async () => {
@@ -318,7 +316,7 @@ describe("bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
// All 24 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
// All 6 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
});
});
-2
View File
@@ -5,7 +5,6 @@ export { cborEncode } from "./cbor.js";
export { type GcStats, gc } from "./gc.js";
export { computeHash, computeSelfHash } from "./hash.js";
export { renderWithTemplate } from "./liquid-render.js";
export { registerOutputTemplates } from "./output-templates.js";
export {
type RenderOptions,
render,
@@ -35,4 +34,3 @@ export {
VariableStore,
} from "./variable-store.js";
export { verify } from "./verify.js";
export { wrapEnvelope } from "./wrap-envelope.js";
-606
View File
@@ -1241,609 +1241,3 @@ describe("Suite 8: Performance & Scalability", () => {
}
});
});
describe("Suite 9: E2E Template Variable Rendering (Issue #52)", () => {
test("9.1 Direct Property Access - Should Render Empty", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema for person object
const personSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
});
// Create node with data
const personHash = await store.put(personSchema, {
name: "Alice",
age: 30,
});
// Register template using direct property access (incorrect syntax)
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"Name: {{ name }}, Age: {{ age }}",
);
varStore.set(`@ucas/template/text/${personSchema}`, templateHash);
// Render - should produce empty values
const output = await renderWithTemplate(store, varStore, personHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBe("Name: , Age: ");
} finally {
varStore.close();
await cleanup();
}
});
test("9.2 Correct Syntax with payload Prefix", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema for person object
const personSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
});
// Create node with data
const personHash = await store.put(personSchema, {
name: "Alice",
age: 30,
});
// Register template using correct payload. prefix
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"Name: {{ payload.name }}, Age: {{ payload.age }}",
);
varStore.set(`@ucas/template/text/${personSchema}`, templateHash);
// Render - should produce correct values
const output = await renderWithTemplate(store, varStore, personHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBe("Name: Alice, Age: 30");
} finally {
varStore.close();
await cleanup();
}
});
test("9.3 CLI Render Command - Template Variable Access", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema and node
const personSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
});
const personHash = await store.put(personSchema, {
name: "Bob",
age: 25,
});
// Register template
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"User: {{ payload.name }}, Age: {{ payload.age }}",
);
varStore.set(`@ucas/template/text/${personSchema}`, templateHash);
// This simulates the CLI flow
const output = await renderWithTemplate(store, varStore, personHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("User: Bob");
expect(output).toContain("Age: 25");
} finally {
varStore.close();
await cleanup();
}
});
test("9.4 Top-Level Primitive Payload - String", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema for simple string
const stringSchema = await putSchema(store, { type: "string" });
// Create node with string payload
const stringHash = await store.put(stringSchema, "Hello World");
// Register template
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"Value is: {{ payload }}",
);
varStore.set(`@ucas/template/text/${stringSchema}`, templateHash);
// Render
const output = await renderWithTemplate(store, varStore, stringHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBe("Value is: Hello World");
} finally {
varStore.close();
await cleanup();
}
});
test("9.5 Top-Level Primitive Payload - Number", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema for number
const numberSchema = await putSchema(store, { type: "number" });
// Create node with number payload
const numberHash = await store.put(numberSchema, 42);
// Register template
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"The answer is {{ payload }}",
);
varStore.set(`@ucas/template/text/${numberSchema}`, templateHash);
// Render
const output = await renderWithTemplate(store, varStore, numberHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBe("The answer is 42");
} finally {
varStore.close();
await cleanup();
}
});
test("9.6 Nested Object Property Access", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema for nested object
const userSchema = await putSchema(store, {
type: "object",
properties: {
user: {
type: "object",
properties: {
name: { type: "string" },
address: {
type: "object",
properties: {
city: { type: "string" },
},
},
},
},
},
});
// Create node with nested data
const userHash = await store.put(userSchema, {
user: {
name: "Bob",
address: {
city: "NYC",
},
},
});
// Register template with deep property access
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"User {{ payload.user.name }} lives in {{ payload.user.address.city }}",
);
varStore.set(`@ucas/template/text/${userSchema}`, templateHash);
// Render
const output = await renderWithTemplate(store, varStore, userHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBe("User Bob lives in NYC");
} finally {
varStore.close();
await cleanup();
}
});
test("9.7 Array Property Access and Iteration", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema with array
const tagsSchema = await putSchema(store, {
type: "object",
properties: {
tags: {
type: "array",
items: { type: "string" },
},
},
});
// Create node with array data
const tagsHash = await store.put(tagsSchema, {
tags: ["javascript", "typescript", "bun"],
});
// Register template with array iteration
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"Tags: {% for tag in payload.tags %}{{ tag }}{% unless forloop.last %}, {% endunless %}{% endfor %}",
);
varStore.set(`@ucas/template/text/${tagsSchema}`, templateHash);
// Render
const output = await renderWithTemplate(store, varStore, tagsHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBe("Tags: javascript, typescript, bun");
} finally {
varStore.close();
await cleanup();
}
});
test("9.8 Missing Property Access - Graceful Handling", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema
const personSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
},
});
// Create node without age property
const personHash = await store.put(personSchema, {
name: "Alice",
});
// Register template that references missing property
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"Name: {{ payload.name }}, Age: {{ payload.age }}",
);
varStore.set(`@ucas/template/text/${personSchema}`, templateHash);
// Render - age should be empty
const output = await renderWithTemplate(store, varStore, personHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBe("Name: Alice, Age: ");
} finally {
varStore.close();
await cleanup();
}
});
test("9.9 Null Property Value Rendering", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema allowing null
const personSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
email: { type: ["string", "null"] },
},
});
// Create node with null email
const personHash = await store.put(personSchema, {
name: "Charlie",
email: null,
});
// Register template
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"Name: {{ payload.name }}, Email: {{ payload.email }}",
);
varStore.set(`@ucas/template/text/${personSchema}`, templateHash);
// Render - email should be empty
const output = await renderWithTemplate(store, varStore, personHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBe("Name: Charlie, Email: ");
} finally {
varStore.close();
await cleanup();
}
});
test("9.10 Boolean Property Rendering", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema with boolean
const userSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
active: { type: "boolean" },
},
});
// Create node with boolean
const userHash = await store.put(userSchema, {
name: "Dave",
active: true,
});
// Register template with conditional
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"User {{ payload.name }} is {% if payload.active %}active{% else %}inactive{% endif %}",
);
varStore.set(`@ucas/template/text/${userSchema}`, templateHash);
// Render
const output = await renderWithTemplate(store, varStore, userHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBe("User Dave is active");
} finally {
varStore.close();
await cleanup();
}
});
test("9.11 Zero and Empty String Values", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema
const dataSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
count: { type: "number" },
},
});
// Create node with empty string and zero
const dataHash = await store.put(dataSchema, {
name: "",
count: 0,
});
// Register template
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"Name: '{{ payload.name }}', Count: {{ payload.count }}",
);
varStore.set(`@ucas/template/text/${dataSchema}`, templateHash);
// Render - zero and empty string should appear
const output = await renderWithTemplate(store, varStore, dataHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBe("Name: '', Count: 0");
} finally {
varStore.close();
await cleanup();
}
});
test("9.12 Special Characters in String Values", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create schema
const textSchema = await putSchema(store, {
type: "object",
properties: {
text: { type: "string" },
},
});
// Create node with special characters
const textHash = await store.put(textSchema, {
text: 'Hello "World" & <tag>',
});
// Register template
const templateSchema = await putSchema(store, { type: "string" });
const templateHash = await store.put(
templateSchema,
"Text: {{ payload.text }}",
);
varStore.set(`@ucas/template/text/${textSchema}`, templateHash);
// Render
const output = await renderWithTemplate(store, varStore, textHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain('Hello "World" & <tag>');
} finally {
varStore.close();
await cleanup();
}
});
});
describe("Suite 10: Context Variable Completeness", () => {
test("10.1 Context Propagation in Recursive Renders", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create child schema and node
const childSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
},
});
const childHash = await store.put(childSchema, { name: "child" });
// Create parent schema and node
const parentSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
child: { type: "string", format: "cas_ref" },
},
});
const parentHash = await store.put(parentSchema, {
name: "parent",
child: childHash,
});
// Register parent template
const templateSchema = await putSchema(store, { type: "string" });
const parentTemplateHash = await store.put(
templateSchema,
"Parent: {{ payload.name }}\n{% render payload.child %}",
);
varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplateHash);
// Register child template that accesses context variables
const childTemplateHash = await store.put(
templateSchema,
"Child: {{ payload.name }}, Hash: {{ hash }}, Resolution: {{ resolution }}",
);
varStore.set(`@ucas/template/text/${childSchema}`, childTemplateHash);
// Render
const output = await renderWithTemplate(store, varStore, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("Parent: parent");
expect(output).toContain("Child: child");
expect(output).toContain(`Hash: ${childHash}`);
expect(output).toContain("Resolution: 0.5");
} finally {
varStore.close();
await cleanup();
}
});
test("10.2 Context Isolation Between Parent and Child", async () => {
const { store, varStore, cleanup } = await createTempVarStore();
try {
// Create child schema and node
const childSchema = await putSchema(store, {
type: "object",
properties: {
custom: { type: "string" },
},
});
const childHash = await store.put(childSchema, {
custom: "child_value",
});
// Create parent schema and node
const parentSchema = await putSchema(store, {
type: "object",
properties: {
custom: { type: "string" },
child: { type: "string", format: "cas_ref" },
},
});
const parentHash = await store.put(parentSchema, {
custom: "parent_value",
child: childHash,
});
// Register parent template
const templateSchema = await putSchema(store, { type: "string" });
const parentTemplateHash = await store.put(
templateSchema,
"Parent custom: {{ payload.custom }}\n{% render payload.child %}",
);
varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplateHash);
// Register child template
const childTemplateHash = await store.put(
templateSchema,
"Child custom: {{ payload.custom }}",
);
varStore.set(`@ucas/template/text/${childSchema}`, childTemplateHash);
// Render
const output = await renderWithTemplate(store, varStore, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("Parent custom: parent_value");
expect(output).toContain("Child custom: child_value");
expect(output).not.toContain("Child custom: parent_value");
} finally {
varStore.close();
await cleanup();
}
});
});
@@ -1,117 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap } from "./bootstrap.js";
import { registerOutputTemplates } from "./output-templates.js";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
import { createVariableStore } from "./variable-store.js";
const OUTPUT_ALIASES = [
"@output/put",
"@output/get",
"@output/has",
"@output/hash",
"@output/verify",
"@output/refs",
"@output/walk",
"@output/list",
"@output/var-set",
"@output/var-get",
"@output/var-delete",
"@output/var-tag",
"@output/var-list",
"@output/template-set",
"@output/template-get",
"@output/template-list",
"@output/template-delete",
"@output/gc",
] as const;
describe("registerOutputTemplates", () => {
let store: Store;
let varStore: VariableStore;
let tempDir: string;
afterEach(async () => {
varStore.close();
await rm(tempDir, { recursive: true });
});
test("registers a template for every @output/* schema", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const registered = await registerOutputTemplates(store, varStore);
expect(Object.keys(registered)).toHaveLength(18);
for (const alias of OUTPUT_ALIASES) {
expect(registered).toHaveProperty(alias);
}
});
test("each template is retrievable via @ucas/template/text/<hash>", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
const aliases = await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
await registerOutputTemplates(store, varStore);
const stringHash = aliases["@string"];
if (!stringHash) throw new Error("@string not found");
for (const alias of OUTPUT_ALIASES) {
const schemaHash = aliases[alias];
if (!schemaHash) throw new Error(`${alias} not found`);
const varName = `@ucas/template/text/${schemaHash}`;
const variable = varStore.get(varName, stringHash);
if (variable === null) throw new Error(`Variable ${varName} not found`);
const templateNode = store.get(variable.value);
if (templateNode === null)
throw new Error(`Template node ${variable.value} not found`);
expect(typeof templateNode.payload).toBe("string");
}
});
test("is idempotent — safe to call multiple times", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const first = await registerOutputTemplates(store, varStore);
const second = await registerOutputTemplates(store, varStore);
expect(first).toEqual(second);
});
test("@output/put template contains payload reference", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
const aliases = await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
await registerOutputTemplates(store, varStore);
const putHash = aliases["@output/put"];
if (!putHash) throw new Error("@output/put not found");
const stringHash = aliases["@string"];
if (!stringHash) throw new Error("@string not found");
const variable = varStore.get(`@ucas/template/text/${putHash}`, stringHash);
if (variable === null)
throw new Error("@output/put template variable not found");
const templateNode = store.get(variable.value);
if (templateNode === null) throw new Error("Template node not found");
expect(templateNode.payload).toBe("{{ payload }}");
});
});
-87
View File
@@ -1,87 +0,0 @@
import { bootstrap } from "./bootstrap.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
const DEFAULT_TEMPLATES: ReadonlyArray<
readonly [alias: string, template: string]
> = [
["@output/put", "{{ payload }}"],
[
"@output/get",
"type: {{ payload.type }}\ntimestamp: {{ payload.timestamp }}",
],
["@output/has", "{{ payload }}"],
["@output/hash", "{{ payload }}"],
["@output/verify", "{{ payload }}"],
["@output/refs", "{% for ref in payload %}{{ ref }}\n{% endfor %}"],
["@output/walk", "{% for item in payload %}{{ item }}\n{% endfor %}"],
["@output/list", "{% for item in payload %}{{ item }}\n{% endfor %}"],
[
"@output/var-set",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-get",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-delete",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-tag",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-list",
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}",
],
[
"@output/template-set",
"schemaHash: {{ payload.schemaHash }}\ncontentHash: {{ payload.contentHash }}",
],
["@output/template-get", "{{ payload }}"],
[
"@output/template-list",
"{% for t in payload %}schemaHash: {{ t.schemaHash }}\ncontentHash: {{ t.contentHash }}\n{% endfor %}",
],
["@output/template-delete", "deleted: {{ payload.deleted }}"],
[
"@output/gc",
"total: {{ payload.total }}\nreachable: {{ payload.reachable }}\ncollected: {{ payload.collected }}\nscanned: {{ payload.scanned }}",
],
];
/**
* Register default LiquidJS templates for all @output/* schemas.
* Each template is stored as a @string CAS node and bound to
* the variable `@ucas/template/text/<schema-hash>`.
*
* Idempotent: safe to call multiple times.
*/
export async function registerOutputTemplates(
store: Store,
varStore: VariableStore,
): Promise<Record<string, Hash>> {
const aliases = await bootstrap(store);
const stringHash = aliases["@string"];
if (stringHash === undefined) {
throw new Error("@string schema not found in bootstrap result");
}
const registered: Record<string, Hash> = {};
for (const [alias, template] of DEFAULT_TEMPLATES) {
const schemaHash = aliases[alias];
if (schemaHash === undefined) {
throw new Error(`Schema alias not found: ${alias}`);
}
const contentHash = await store.put(stringHash, template);
const varName = `@ucas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
registered[alias] = contentHash;
}
return registered;
}
+7 -102
View File
@@ -1,10 +1,9 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { render, renderAsync, renderDirect } from "./render.js";
import { render, renderDirect } from "./render.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
import type { Hash } from "./types.js";
import { CasNodeNotFoundError } from "./variable-store.js";
describe("Suite 1: Basic Rendering (No Nesting)", () => {
test("1.1 Render Simple Primitives", async () => {
@@ -66,14 +65,13 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
expect(output.trim()).toBe(`cas:${hash}`);
});
test("1.5 Render Non-existent Hash Throws Error", () => {
test("1.5 Render Non-existent Hash", () => {
const store = createMemoryStore();
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
// Non-existent root node should throw
expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError);
expect(() => render(store, fakeHash)).toThrow("CAS node not found");
expect(() => render(store, fakeHash)).toThrow(fakeHash);
// Non-existent node renders as cas: reference
const output = render(store, fakeHash);
expect(output.trim()).toBe(`cas:${fakeHash}`);
});
});
@@ -795,10 +793,9 @@ describe("Suite 6: Schema Integration", () => {
test("6.5 Schema-less Node (Bootstrap Node)", async () => {
const store = createMemoryStore();
const types = await bootstrap(store);
const schemaHash = types["@schema"];
const metaHash = await bootstrap(store);
const output = render(store, schemaHash);
const output = render(store, metaHash);
// Should render without recursive expansion
expect(output).toBeTruthy();
@@ -1058,95 +1055,3 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
expect(output).toContain("key: val");
});
});
describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
test("10.1 renderAsync() throws CasNodeNotFoundError for missing root hash", async () => {
const store = createMemoryStore();
await bootstrap(store);
const fakeHash = "AAAAAAAAAAAAA" as Hash;
await expect(renderAsync(store, fakeHash)).rejects.toThrow(
CasNodeNotFoundError,
);
await expect(renderAsync(store, fakeHash)).rejects.toThrow(
"CAS node not found",
);
await expect(renderAsync(store, fakeHash)).rejects.toThrow(fakeHash);
});
test("10.2 render() throws CasNodeNotFoundError for missing root hash", () => {
const store = createMemoryStore();
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError);
expect(() => render(store, fakeHash)).toThrow("CAS node not found");
expect(() => render(store, fakeHash)).toThrow(fakeHash);
});
test("10.3 renderDirect() does NOT throw for non-existent type hash", () => {
const store = createMemoryStore();
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, { key: "value" }, store, null);
expect(output).toContain("key: value");
});
test("10.4 Missing nested node renders as cas: reference (no error)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const parentSchema = await putSchema(store, {
type: "object",
properties: {
title: { type: "string" },
child: { type: "string", format: "cas_ref" },
},
});
const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash;
const parentHash = await store.put(parentSchema, {
title: "root",
child: fakeChildHash,
});
const output = render(store, parentHash);
expect(output).toContain("title: root");
expect(output).toContain(`cas:${fakeChildHash}`);
});
test("10.5 Resolution below epsilon renders as cas: reference (no error)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 3-level chain
let currentHash: Hash | null = null;
for (let i = 2; i >= 0; i--) {
currentHash = await store.put(nodeSchema, {
level: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 1.0,
decay: 0.1,
epsilon: 0.5,
});
// Level 0 should be expanded (resolution = 1.0 > 0.5)
expect(output).toContain("level: 0");
// Level 1+ should be cas: references (0.1, 0.01 < 0.5)
expect(output).toContain("cas:");
});
});
-12
View File
@@ -2,7 +2,6 @@ import { renderWithTemplate } from "./liquid-render.js";
import { collectRefs, getSchema, putSchema, refs } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
import { CasNodeNotFoundError } from "./variable-store.js";
export type RenderOptions = {
resolution?: number; // (0, 1], default 1.0
@@ -56,11 +55,6 @@ export function render(
): string {
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
// Check if root node exists
if (store.get(hash) === null) {
throw new CasNodeNotFoundError(hash);
}
const visited = new Set<Hash>();
return renderNode(store, hash, resolution, decay, epsilon, visited);
}
@@ -76,12 +70,6 @@ export async function renderAsync(
options?: RenderOptions,
): Promise<string> {
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
// Check if root node exists
if (store.get(hash) === null) {
throw new CasNodeNotFoundError(hash);
}
const varStore = options?.varStore;
// If varStore provided, try template rendering first
-101
View File
@@ -388,107 +388,6 @@ describe("bootstrap meta-schema self-reference", () => {
expect(dataNode.type).not.toBe(metaHash);
});
// ── P1 leaf constraints ──────────────────────────────────────────────────
test("accepts schema with numeric constraints (minimum/maximum)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "number",
minimum: 0,
maximum: 100,
exclusiveMinimum: -1,
exclusiveMaximum: 101,
});
expect(hash).toHaveLength(13);
// validate a conforming payload
const nodeHash = await store.put(hash, 42);
const node = store.get(nodeHash) as CasNode;
expect(validate(store, node)).toBe(true);
// validate a non-conforming payload
const badHash = await store.put(hash, 200);
const badNode = store.get(badHash) as CasNode;
expect(validate(store, badNode)).toBe(false);
});
test("accepts schema with string constraints (minLength/maxLength/pattern)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "string",
minLength: 1,
maxLength: 10,
pattern: "^[a-z]+$",
});
expect(hash).toHaveLength(13);
const goodHash = await store.put(hash, "hello");
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
const badHash = await store.put(hash, "HELLO");
expect(validate(store, store.get(badHash) as CasNode)).toBe(false);
});
test("accepts schema with array constraints (minItems/maxItems/uniqueItems)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "array",
items: { type: "number" },
minItems: 1,
maxItems: 3,
uniqueItems: true,
});
expect(hash).toHaveLength(13);
const goodHash = await store.put(hash, [1, 2, 3]);
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
const tooMany = await store.put(hash, [1, 2, 3, 4]);
expect(validate(store, store.get(tooMany) as CasNode)).toBe(false);
const dupes = await store.put(hash, [1, 1]);
expect(validate(store, store.get(dupes) as CasNode)).toBe(false);
});
test("rejects schema with wrong constraint types", async () => {
const store = createMemoryStore();
await expect(
putSchema(store, { type: "number", minimum: "zero" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { type: "string", maxLength: true } as never),
).rejects.toThrow();
await expect(
putSchema(store, { type: "array", uniqueItems: 1 } as never),
).rejects.toThrow();
});
test("accepts schema with nested property constraints", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 50 },
age: { type: "number", minimum: 0, maximum: 150 },
scores: {
type: "array",
items: { type: "number" },
minItems: 1,
uniqueItems: true,
},
},
required: ["name"],
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, {
name: "Alice",
age: 30,
scores: [95, 87],
});
expect(validate(store, store.get(good) as CasNode)).toBe(true);
});
test("bootstrap is idempotent across putSchema calls", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
-34
View File
@@ -35,17 +35,6 @@ const ALLOWED_SCHEMA_KEYS = new Set([
"enum",
"const",
"description",
// P1 leaf constraints (no collectRefs impact)
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"minLength",
"maxLength",
"pattern",
"minItems",
"maxItems",
"uniqueItems",
]);
const JSON_SCHEMA_TYPES = new Set([
@@ -137,29 +126,6 @@ function isValidSchema(value: unknown): boolean {
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
}
// P1 leaf constraints — type checks only
if ("minimum" in schema && typeof schema.minimum !== "number") return false;
if ("maximum" in schema && typeof schema.maximum !== "number") return false;
if (
"exclusiveMinimum" in schema &&
typeof schema.exclusiveMinimum !== "number"
)
return false;
if (
"exclusiveMaximum" in schema &&
typeof schema.exclusiveMaximum !== "number"
)
return false;
if ("minLength" in schema && typeof schema.minLength !== "number")
return false;
if ("maxLength" in schema && typeof schema.maxLength !== "number")
return false;
if ("pattern" in schema && typeof schema.pattern !== "string") return false;
if ("minItems" in schema && typeof schema.minItems !== "number") return false;
if ("maxItems" in schema && typeof schema.maxItems !== "number") return false;
if ("uniqueItems" in schema && typeof schema.uniqueItems !== "boolean")
return false;
return true;
}
+2 -5
View File
@@ -36,11 +36,8 @@ export class SchemaMismatchError extends Error {
}
export class CasNodeNotFoundError extends Error {
constructor(
public readonly hash: string,
message?: string,
) {
super(message ?? `CAS node not found: ${hash}`);
constructor(hash: string) {
super(`CAS node not found: ${hash}`);
this.name = "CasNodeNotFoundError";
}
}
@@ -1,85 +0,0 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { createMemoryStore } from "./store.js";
import { wrapEnvelope } from "./wrap-envelope.js";
describe("wrapEnvelope", () => {
test("resolves @output/put alias and returns envelope", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@output/put", "AAAAAAAAAAAAA");
expect(envelope.type).toBe(aliases["@output/put"]);
expect(envelope.value).toBe("AAAAAAAAAAAAA");
});
test("resolves @output/has alias with boolean value", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@output/has", true);
expect(envelope.type).toBe(aliases["@output/has"]);
expect(envelope.value).toBe(true);
});
test("resolves @output/gc alias with object value", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const gcStats = { total: 100, reachable: 80, collected: 20, scanned: 5 };
const envelope = await wrapEnvelope(store, "@output/gc", gcStats);
expect(envelope.type).toBe(aliases["@output/gc"]);
expect(envelope.value).toEqual(gcStats);
});
test("resolves primitive alias @string", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@string", "hello");
expect(envelope.type).toBe(aliases["@string"]);
expect(envelope.value).toBe("hello");
});
test("throws for unknown alias", async () => {
const store = createMemoryStore();
await bootstrap(store);
await expect(
wrapEnvelope(store, "@output/nonexistent", "value"),
).rejects.toThrow("Unknown schema alias: @output/nonexistent");
});
test("is idempotent — same alias returns same type hash", async () => {
const store = createMemoryStore();
const first = await wrapEnvelope(store, "@output/verify", "ok");
const second = await wrapEnvelope(store, "@output/verify", "corrupted");
expect(first.type).toBe(second.type);
expect(first.value).toBe("ok");
expect(second.value).toBe("corrupted");
});
test("preserves complex object values without mutation", async () => {
const store = createMemoryStore();
await bootstrap(store);
const original = {
name: "test",
schema: "AAAAAAAAAAAAA",
value: "BBBBBBBBBBBBB",
created: 1000,
updated: 2000,
tags: { env: "prod" },
labels: ["stable"],
};
const envelope = await wrapEnvelope(store, "@output/var-set", original);
expect(envelope.value).toEqual(original);
});
});
-19
View File
@@ -1,19 +0,0 @@
import { bootstrap } from "./bootstrap.js";
import type { Hash, Store } from "./types.js";
/**
* Resolve a schema alias (e.g. "@output/put") to its hash via bootstrap,
* then return a typed envelope ready for store.put() or direct rendering.
*/
export async function wrapEnvelope(
store: Store,
schemaAlias: string,
value: unknown,
): Promise<{ type: Hash; value: unknown }> {
const aliases = await bootstrap(store);
const typeHash = aliases[schemaAlias];
if (typeHash === undefined) {
throw new Error(`Unknown schema alias: ${schemaAlias}`);
}
return { type: typeHash, value };
}
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
# Prevent publishing packages that still reference workspace:* dependencies
if grep -q '"workspace:' package.json 2>/dev/null; then
echo "❌ Found workspace:* dependencies in package.json — cannot publish directly."
echo " Use 'changeset publish' which resolves workspace protocol automatically."
grep '"workspace:' package.json
exit 1
fi