Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac87d2ccb3 |
@@ -2,4 +2,3 @@ node_modules/
|
||||
dist/
|
||||
*.d.ts.map
|
||||
*.tsbuildinfo
|
||||
.worktrees/
|
||||
|
||||
@@ -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}}}" }
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
"liquidjs": "^10.27.0",
|
||||
"xxhash-wasm": "^1.1.0",
|
||||
},
|
||||
},
|
||||
@@ -142,8 +141,6 @@
|
||||
|
||||
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
|
||||
|
||||
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="],
|
||||
@@ -206,8 +203,6 @@
|
||||
|
||||
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
|
||||
|
||||
"liquidjs": ["liquidjs@10.27.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w=="],
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
|
||||
|
||||
@@ -1,259 +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": "ASE7K6A0HG8W9",
|
||||
"value": {
|
||||
"payload": {
|
||||
"age": 30,
|
||||
"name": "Alice",
|
||||
},
|
||||
"type": "7XX5H51CVD9H0",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
|
||||
{
|
||||
"type": "8E2M8H30BHXS8",
|
||||
"value": "ok",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `""`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `"ERARPP19YJT05"`;
|
||||
|
||||
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"tags": {},
|
||||
"value": "ERARPP19YJT05",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"tags": {},
|
||||
"value": "ERARPP19YJT05",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"tags": {},
|
||||
"value": "ERARPP19YJT05",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"tags": {},
|
||||
"value": "ERARPP19YJT05",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"tags": {},
|
||||
"value": "F68P1BZ46YDXM",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"value": {
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "ERARPP19YJT05",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "ERARPP19YJT05",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "ERARPP19YJT05",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "ERARPP19YJT05",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "7XX5H51CVD9H0",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "ERARPP19YJT05",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=7XX5H51CVD9H0"`;
|
||||
|
||||
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
|
||||
"{
|
||||
"schemaHash": "7XX5H51CVD9H0",
|
||||
"contentHash": "FC8WACA792B6F"
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `"Name: {{ payload.name }}, Age: {{ payload.age }}"`;
|
||||
|
||||
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
|
||||
"[
|
||||
{
|
||||
"schemaHash": "7XX5H51CVD9H0",
|
||||
"preview": "Name: {{ payload.name }}, Age: {{ payload.age }}"
|
||||
}
|
||||
]"
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
|
||||
"{
|
||||
"deleted": true
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 7XX5H51CVD9H0"`;
|
||||
|
||||
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]
|
||||
|
||||
Commands:
|
||||
put <type-hash> <file.json> Store node, print { type, value } envelope (value=hash)
|
||||
get <hash> Print node as { type, value } envelope
|
||||
has <hash> Print { type, value } envelope (value=boolean)
|
||||
verify <hash> Verify integrity + schema → { type, value } (value=ok/corrupted/invalid)
|
||||
refs <hash> List direct cas_ref edges
|
||||
walk <hash> [--format tree] Recursive traversal
|
||||
hash <type-hash> <file.json> Compute hash without storing → { type, value } envelope
|
||||
render <hash> [options] Render node as YAML with resolution decay
|
||||
render --pipe/-p [options] Render { type, value } from stdin
|
||||
list --type <hash-or-alias> List hashes for a type → { type, value } envelope (value=string[])
|
||||
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)
|
||||
--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 { type, value } JSON from stdin for render"
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,522 +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;
|
||||
}
|
||||
|
||||
// ---- 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(stdout).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("4.2 template get returns template text", async () => {
|
||||
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toBe("Name: {{ payload.name }}, Age: {{ payload.age }}");
|
||||
expect(stdout).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("4.3 template list shows registered templates", async () => {
|
||||
const { stdout, exitCode } = await runCli(["template", "list"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain(typeHash);
|
||||
expect(stdout).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("4.4 template delete removes template", async () => {
|
||||
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(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 = JSON.parse(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");
|
||||
});
|
||||
});
|
||||
+152
-364
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
|
||||
@@ -15,16 +15,14 @@ import {
|
||||
InvalidVariableNameError,
|
||||
putSchema,
|
||||
refs,
|
||||
renderAsync,
|
||||
renderDirect,
|
||||
render,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
validate,
|
||||
verify,
|
||||
walk,
|
||||
wrapEnvelope,
|
||||
} from "@uncaged/json-cas";
|
||||
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
// ---- Argument parsing ----
|
||||
|
||||
@@ -40,8 +38,6 @@ const VALUE_FLAGS = new Set([
|
||||
"resolution",
|
||||
"decay",
|
||||
"epsilon",
|
||||
"inline",
|
||||
"type",
|
||||
]);
|
||||
|
||||
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
@@ -113,44 +109,22 @@ function readJsonFile(file: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the filesystem-backed CAS store.
|
||||
* Automatically creates directory and bootstraps if needed.
|
||||
*/
|
||||
async function openStore(): Promise<Store> {
|
||||
const fullPath = resolve(storePath);
|
||||
return await openFsStore(fullPath);
|
||||
function openStore(): Store {
|
||||
return createFsStore(resolve(storePath));
|
||||
}
|
||||
|
||||
async function openVarStore(): Promise<VariableStore> {
|
||||
const store = await openStore();
|
||||
function openVarStore(): VariableStore {
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
return createVariableStore(resolve(varDbPath), store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a type-hash, handling @ aliases
|
||||
* If the input starts with @, resolve it via bootstrap
|
||||
* Otherwise, return the hash as-is
|
||||
*/
|
||||
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
|
||||
if (typeHashOrAlias.startsWith("@")) {
|
||||
const store = await openStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const resolvedHash = builtinSchemas[typeHashOrAlias];
|
||||
if (!resolvedHash) {
|
||||
die(`Schema not found: ${typeHashOrAlias}`);
|
||||
}
|
||||
return resolvedHash;
|
||||
}
|
||||
return typeHashOrAlias;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Variable schema's CAS hash
|
||||
* This is the type hash used in JSON envelopes
|
||||
*/
|
||||
async function getVariableSchemaHash(): Promise<Hash> {
|
||||
const store = await openStore();
|
||||
const store = openStore();
|
||||
|
||||
// Define the Variable JSON Schema (updated for new model with composite key)
|
||||
const variableSchema: JSONSchema = {
|
||||
@@ -228,71 +202,105 @@ function parseTagsLabels(args: string[]): {
|
||||
|
||||
// ---- Commands ----
|
||||
|
||||
async function cmdInit(): Promise<void> {
|
||||
const dir = resolve(storePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const store = createFsStore(dir);
|
||||
const hash = await bootstrap(store);
|
||||
console.log(hash);
|
||||
}
|
||||
|
||||
async function cmdBootstrap(): Promise<void> {
|
||||
const store = openStore();
|
||||
const hash = await bootstrap(store);
|
||||
console.log(hash);
|
||||
}
|
||||
|
||||
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();
|
||||
const hash = await putSchema(store, schema);
|
||||
console.log(hash);
|
||||
}
|
||||
|
||||
async function cmdSchemaGet(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas schema get <type-hash>");
|
||||
const store = openStore();
|
||||
const schema = getSchema(store, hash);
|
||||
if (schema === null) die(`Schema not found: ${hash}`);
|
||||
out(schema);
|
||||
}
|
||||
|
||||
async function cmdSchemaList(): Promise<void> {
|
||||
const store = openStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
for (const hash of store.listByType(metaHash)) {
|
||||
if (hash === metaHash) continue;
|
||||
const node = store.get(hash);
|
||||
if (node !== null) {
|
||||
const schema = node.payload as JSONSchema;
|
||||
const name =
|
||||
(schema.title as string | undefined) ??
|
||||
(schema.description as string | undefined) ??
|
||||
"(unnamed)";
|
||||
console.log(`${hash} ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdSchemaValidate(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas schema validate <hash>");
|
||||
const store = openStore();
|
||||
const node = store.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
const valid = validate(store, node);
|
||||
console.log(valid ? "valid" : "invalid");
|
||||
}
|
||||
|
||||
async function cmdPut(args: string[]): Promise<void> {
|
||||
const typeHashOrAlias = args[0];
|
||||
const typeHash = args[0];
|
||||
const file = args[1];
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas put <type-hash> <file.json>");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
if (!typeHash || !file) die("Usage: json-cas put <type-hash> <file.json>");
|
||||
const payload = readJsonFile(file);
|
||||
const store = await openStore();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
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);
|
||||
@@ -304,7 +312,7 @@ async function cmdRefs(args: string[]): Promise<void> {
|
||||
async function cmdWalk(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas walk <hash> [--format tree]");
|
||||
const store = await openStore();
|
||||
const store = openStore();
|
||||
const format = flags.format;
|
||||
|
||||
if (format === "tree") {
|
||||
@@ -341,32 +349,23 @@ async function cmdWalk(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdHash(args: string[]): Promise<void> {
|
||||
const typeHashOrAlias = args[0];
|
||||
const typeHash = args[0];
|
||||
const file = args[1];
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas hash <type-hash> <file.json>");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
if (!typeHash || !file) die("Usage: json-cas hash <type-hash> <file.json>");
|
||||
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> {
|
||||
const isPipe = flags.pipe === true || flags.p === true;
|
||||
const hash = args[0];
|
||||
|
||||
if (isPipe && hash) {
|
||||
die("Cannot use --pipe/-p with a hash argument. Use one or the other.");
|
||||
}
|
||||
|
||||
if (!isPipe && !hash) {
|
||||
if (!hash) {
|
||||
die(
|
||||
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]\n ucas render --pipe/-p [--resolution <n>] [--decay <n>] [--epsilon <n>]",
|
||||
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]",
|
||||
);
|
||||
}
|
||||
|
||||
const store = await openStore();
|
||||
const store = openStore();
|
||||
|
||||
// Parse numeric options
|
||||
const resolution =
|
||||
@@ -394,67 +393,10 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
if (isPipe) {
|
||||
// Read { type, value } JSON from stdin
|
||||
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 a { type, value } JSON envelope.");
|
||||
}
|
||||
|
||||
let envelope: { type: string; value: unknown };
|
||||
try {
|
||||
envelope = JSON.parse(input) as { type: string; value: unknown };
|
||||
} catch {
|
||||
die("Invalid JSON on stdin. Expected { type, value } envelope.");
|
||||
return; // unreachable, for TS
|
||||
}
|
||||
|
||||
if (
|
||||
typeof envelope !== "object" ||
|
||||
envelope === null ||
|
||||
typeof envelope.type !== "string" ||
|
||||
!("value" in envelope)
|
||||
) {
|
||||
die("Invalid envelope. Expected { type: string, value: unknown }.");
|
||||
}
|
||||
|
||||
// Validate type hash format: 13-char uppercase Crockford Base32
|
||||
if (!/^[0-9A-Z]{13}$/.test(envelope.type)) {
|
||||
die(
|
||||
`Invalid type hash: "${envelope.type}". Expected 13-character uppercase Crockford Base32 string.`,
|
||||
);
|
||||
}
|
||||
|
||||
const output = renderDirect(
|
||||
envelope.type as Hash,
|
||||
envelope.value,
|
||||
store,
|
||||
{
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
},
|
||||
);
|
||||
process.stdout.write(output);
|
||||
} else {
|
||||
const varStore = await openVarStore();
|
||||
const output = await renderAsync(store, hash, {
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
varStore,
|
||||
});
|
||||
// Output to stdout without JSON wrapping (raw output)
|
||||
process.stdout.write(output);
|
||||
}
|
||||
const output = render(store, hash, { resolution, decay, epsilon });
|
||||
// Output to stdout without JSON wrapping (raw YAML)
|
||||
process.stdout.write(output);
|
||||
} catch (error) {
|
||||
if (error instanceof CasNodeNotFoundError) {
|
||||
die(`Error: Node not found: ${error.hash}`);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
die(error.message);
|
||||
}
|
||||
@@ -462,6 +404,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];
|
||||
@@ -471,7 +426,7 @@ async function cmdVarSet(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
|
||||
}
|
||||
|
||||
const varStore = await openVarStore();
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
// Parse tags/labels from --tag flags
|
||||
@@ -522,7 +477,7 @@ async function cmdVarGet(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var get <name> --schema <hash>");
|
||||
}
|
||||
|
||||
const varStore = await openVarStore();
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
const variable = varStore.get(name, schema);
|
||||
@@ -544,7 +499,7 @@ async function cmdVarDelete(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var delete <name> [--schema <hash>]");
|
||||
}
|
||||
|
||||
const varStore = await openVarStore();
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
if (schema !== undefined) {
|
||||
@@ -581,7 +536,7 @@ async function cmdVarTag(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
|
||||
}
|
||||
|
||||
const varStore = await openVarStore();
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
|
||||
@@ -613,7 +568,7 @@ async function cmdVarList(args: string[]): Promise<void> {
|
||||
const schema = flags.schema as string | undefined;
|
||||
const tagFlags = flags.tag;
|
||||
|
||||
const varStore = await openVarStore();
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
// Parse tags/labels from --tag flags
|
||||
@@ -647,172 +602,8 @@ async function cmdVarList(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateSet(args: string[]): Promise<void> {
|
||||
const schemaHash = args[0];
|
||||
const inlineFlag = flags.inline;
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
|
||||
}
|
||||
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
// Validate schema hash exists in CAS
|
||||
if (!store.has(schemaHash)) {
|
||||
die(`Error: Schema hash not found in CAS: ${schemaHash}`);
|
||||
}
|
||||
|
||||
// Determine content source
|
||||
let content: string;
|
||||
|
||||
if (typeof inlineFlag === "string") {
|
||||
// --inline mode
|
||||
const fileArg = args[1];
|
||||
if (fileArg !== undefined && !fileArg.startsWith("--")) {
|
||||
die("Error: Cannot specify both file and --inline");
|
||||
}
|
||||
content = inlineFlag;
|
||||
} else if (inlineFlag === true) {
|
||||
// --inline flag present but no value
|
||||
const contentArg = args[1];
|
||||
if (!contentArg) {
|
||||
die(
|
||||
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
|
||||
);
|
||||
}
|
||||
content = contentArg;
|
||||
} else {
|
||||
// File mode
|
||||
const file = args[1];
|
||||
if (!file) {
|
||||
die(
|
||||
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
|
||||
);
|
||||
}
|
||||
if (!existsSync(file)) {
|
||||
die(`Error: File not found: ${file}`);
|
||||
}
|
||||
content = readFileSync(file, "utf-8");
|
||||
}
|
||||
|
||||
// Store content in CAS under @string schema
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
const contentHash = await store.put(stringHash, content);
|
||||
|
||||
// Create variable binding: @ucas/template/text/<schema-hash>
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
varStore.set(varName, contentHash);
|
||||
|
||||
out({
|
||||
schemaHash,
|
||||
contentHash,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CasNodeNotFoundError) {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateGet(args: string[]): Promise<void> {
|
||||
const schemaHash = args[0];
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template get <schema-hash>");
|
||||
}
|
||||
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
const variable = varStore.get(varName, stringHash);
|
||||
|
||||
if (variable === null) {
|
||||
die(`Error: Template not found for schema: ${schemaHash}`);
|
||||
}
|
||||
|
||||
// Get the content from CAS
|
||||
const node = store.get(variable.value);
|
||||
if (node === null) {
|
||||
die(`Error: Content not found in CAS: ${variable.value}`);
|
||||
}
|
||||
|
||||
// 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 varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
const variables = varStore.list({
|
||||
namePrefix: "@ucas/template/text/",
|
||||
schema: stringHash,
|
||||
});
|
||||
|
||||
const templates = variables.map((v) => {
|
||||
const schemaHash = v.name.replace("@ucas/template/text/", "");
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateDelete(args: string[]): Promise<void> {
|
||||
const schemaHash = args[0];
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template delete <schema-hash>");
|
||||
}
|
||||
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
varStore.remove(varName, stringHash);
|
||||
|
||||
out({ deleted: true });
|
||||
} catch (e) {
|
||||
if (e instanceof VariableNotFoundError) {
|
||||
die(`Error: Template not found for schema: ${schemaHash}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdGc(_args: string[]): Promise<void> {
|
||||
const store = await openStore();
|
||||
const store = createFsStore(storePath);
|
||||
const varStore = createVariableStore(varDbPath, store);
|
||||
|
||||
try {
|
||||
@@ -823,40 +614,31 @@ async function cmdGc(_args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdList(_args: string[]): Promise<void> {
|
||||
const typeFlag = flags.type;
|
||||
if (typeof typeFlag !== "string")
|
||||
die("Usage: json-cas list --type <hash-or-alias>");
|
||||
const typeHash = await resolveTypeHash(typeFlag);
|
||||
const store = await openStore();
|
||||
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]
|
||||
|
||||
Commands:
|
||||
put <type-hash> <file.json> Store node, print { type, value } envelope (value=hash)
|
||||
get <hash> Print node as { type, value } envelope
|
||||
has <hash> Print { type, value } envelope (value=boolean)
|
||||
verify <hash> Verify integrity + schema → { type, value } (value=ok/corrupted/invalid)
|
||||
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 → { type, value } envelope
|
||||
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
|
||||
list --type <hash-or-alias> List hashes for a type → { type, value } envelope (value=string[])
|
||||
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:
|
||||
@@ -865,11 +647,9 @@ Flags:
|
||||
--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 { type, value } JSON from stdin for render`);
|
||||
--epsilon <n> Cutoff threshold for render (default: 0.01)`);
|
||||
}
|
||||
|
||||
// ---- Dispatch ----
|
||||
@@ -882,6 +662,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;
|
||||
@@ -914,8 +723,8 @@ switch (cmd) {
|
||||
await cmdRender(rest);
|
||||
break;
|
||||
|
||||
case "list":
|
||||
await cmdList(rest);
|
||||
case "cat":
|
||||
await cmdCat(rest);
|
||||
break;
|
||||
|
||||
case "var": {
|
||||
@@ -942,27 +751,6 @@ switch (cmd) {
|
||||
break;
|
||||
}
|
||||
|
||||
case "template": {
|
||||
const [sub, ...subRest] = rest;
|
||||
switch (sub) {
|
||||
case "set":
|
||||
await cmdTemplateSet(subRest);
|
||||
break;
|
||||
case "get":
|
||||
await cmdTemplateGet(subRest);
|
||||
break;
|
||||
case "list":
|
||||
await cmdTemplateList(subRest);
|
||||
break;
|
||||
case "delete":
|
||||
await cmdTemplateDelete(subRest);
|
||||
break;
|
||||
default:
|
||||
die(`Unknown template subcommand: ${sub ?? "(none)"}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "gc":
|
||||
await cmdGc(rest);
|
||||
break;
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
// ---- Test helpers ----
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let varDbPath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
testDir = join(
|
||||
tmpdir(),
|
||||
`json-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
cliPath = join(import.meta.dir, "index.ts");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test directory
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Run CLI command and return stdout, stderr, and exit code
|
||||
*/
|
||||
async function runCli(...args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--store",
|
||||
storePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
...args,
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: proc.exitCode ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bootstrap @string type hash
|
||||
*/
|
||||
async function getStringHash(store: Store): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
return builtinSchemas["@string"] ?? "";
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
describe("template set", () => {
|
||||
test("set template from file", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "Hello {{name}}!");
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
templateFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
expect(output.schemaHash).toBe(stringHash);
|
||||
});
|
||||
|
||||
test("set template with --inline flag", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
"Inline template content",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
expect(output.schemaHash).toBe(stringHash);
|
||||
});
|
||||
|
||||
test("update existing template (idempotent)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "Version 1");
|
||||
|
||||
// Set first time
|
||||
await runCli("template", "set", stringHash, templateFile);
|
||||
|
||||
// Update with new content
|
||||
writeFileSync(templateFile, "Version 2");
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
templateFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
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(getOut).toBe("Version 2");
|
||||
});
|
||||
|
||||
test("error when file not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"/nonexistent/file.txt",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
});
|
||||
|
||||
test("error when schema hash invalid", async () => {
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "content");
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
"INVALID_HASH",
|
||||
templateFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
});
|
||||
|
||||
test("error when both file and --inline provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "content");
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
templateFile,
|
||||
"--inline",
|
||||
"inline content",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
});
|
||||
|
||||
test("support multi-line templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const multilineContent = "Line 1\nLine 2\nLine 3";
|
||||
const { exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
multilineContent,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify content
|
||||
const { stdout: getOut } = await runCli("template", "get", stringHash);
|
||||
expect(getOut).toBe(multilineContent);
|
||||
});
|
||||
|
||||
test("support empty templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
});
|
||||
|
||||
test("error when neither file nor --inline provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "set", stringHash);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("support templates with special characters", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const specialContent = "Template with {{var}} and $env and @ref";
|
||||
const { exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
specialContent,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify content preserved
|
||||
const { stdout: getOut } = await runCli("template", "get", stringHash);
|
||||
expect(getOut).toBe(specialContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template get", () => {
|
||||
test("retrieve template as raw text", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Hello {{name}}!";
|
||||
await runCli("template", "set", stringHash, "--inline", content);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"get",
|
||||
stringHash,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout).toBe(content);
|
||||
});
|
||||
|
||||
test("error when template not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "get", stringHash);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
expect(stderr).toContain("not found");
|
||||
});
|
||||
|
||||
test("preserve exact whitespace", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Note: runCli helper trims stdout, so we test with content that doesn't have leading/trailing whitespace
|
||||
// The actual CLI preserves whitespace correctly
|
||||
const content = "spaces\n\ttabs\t\nmixed";
|
||||
await runCli("template", "set", stringHash, "--inline", content);
|
||||
|
||||
const { stdout } = await runCli("template", "get", stringHash);
|
||||
|
||||
expect(stdout).toBe(content);
|
||||
});
|
||||
|
||||
test("support multi-line templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Note: runCli helper trims stdout, so trailing newline will be removed
|
||||
const multiline = "Line 1\nLine 2\nLine 3";
|
||||
await runCli("template", "set", stringHash, "--inline", multiline);
|
||||
|
||||
const { stdout } = await runCli("template", "get", stringHash);
|
||||
|
||||
expect(stdout).toBe(multiline);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template list", () => {
|
||||
test("list all templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create multiple templates
|
||||
await runCli("template", "set", stringHash, "--inline", "Template 1");
|
||||
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
|
||||
|
||||
const { stdout, exitCode } = await runCli("template", "list");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(Array.isArray(output)).toBe(true);
|
||||
expect(output.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Check structure
|
||||
const item = output[0];
|
||||
expect(item).toHaveProperty("schemaHash");
|
||||
expect(item).toHaveProperty("preview");
|
||||
});
|
||||
|
||||
test("preview truncation for long content", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const longContent = "a".repeat(200);
|
||||
await runCli("template", "set", stringHash, "--inline", longContent);
|
||||
|
||||
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);
|
||||
expect(item).toBeDefined();
|
||||
if (item) {
|
||||
expect(item.preview.length).toBeLessThan(longContent.length);
|
||||
expect(item.preview).toContain("...");
|
||||
}
|
||||
});
|
||||
|
||||
test("empty list when no templates", async () => {
|
||||
const { stdout, exitCode } = await runCli("template", "list");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(Array.isArray(output)).toBe(true);
|
||||
expect(output.length).toBe(0);
|
||||
});
|
||||
|
||||
test("exclude non-template variables", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create a template
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
// Create a regular variable (not under @ucas/template/text/)
|
||||
const hash = await store.put(stringHash, "regular var content");
|
||||
await runCli("var", "set", "regular/var", hash);
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
// Should only contain template variables
|
||||
for (const item of output) {
|
||||
expect(item.schemaHash).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("output JSON array format", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Test");
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
// Should be valid JSON
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(Array.isArray(output)).toBe(true);
|
||||
});
|
||||
|
||||
test("preview shows beginning of content", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Start of template...";
|
||||
await runCli("template", "set", stringHash, "--inline", content);
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
const output = JSON.parse(stdout) as Array<{
|
||||
schemaHash: string;
|
||||
preview: string;
|
||||
}>;
|
||||
const item = output.find((i) => i.schemaHash === stringHash);
|
||||
if (item) {
|
||||
expect(item.preview).toContain("Start");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("template delete", () => {
|
||||
test("delete template variable binding", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("deleted");
|
||||
expect(output.deleted).toBe(true);
|
||||
|
||||
// Verify template is gone
|
||||
const { exitCode: getExitCode } = await runCli(
|
||||
"template",
|
||||
"get",
|
||||
stringHash,
|
||||
);
|
||||
expect(getExitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("error when template not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "delete", stringHash);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
expect(stderr).toContain("not found");
|
||||
});
|
||||
|
||||
test("deletion does not affect other templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create two templates
|
||||
await runCli("template", "set", stringHash, "--inline", "Template 1");
|
||||
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
|
||||
|
||||
// Delete first template
|
||||
await runCli("template", "delete", stringHash);
|
||||
|
||||
// Verify second still exists
|
||||
const { stdout } = await runCli("template", "list");
|
||||
const output = JSON.parse(stdout) as Array<{
|
||||
schemaHash: string;
|
||||
preview: string;
|
||||
}>;
|
||||
|
||||
// Should not find deleted template
|
||||
const deleted = output.find((i) => i.schemaHash === stringHash);
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
test("CAS content remains after variable deletion", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Content");
|
||||
|
||||
// Get the content hash before deletion
|
||||
const { stdout: setOut } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
"Content",
|
||||
);
|
||||
const { contentHash } = JSON.parse(setOut);
|
||||
|
||||
// Delete the template variable
|
||||
await runCli("template", "delete", stringHash);
|
||||
|
||||
// Verify CAS node still exists
|
||||
const { exitCode: hasExitCode } = await runCli("has", contentHash);
|
||||
expect(hasExitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("deletion is non-idempotent (second delete fails)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
// First deletion succeeds
|
||||
const { exitCode: firstExit } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
expect(firstExit).toBe(0);
|
||||
|
||||
// Second deletion fails
|
||||
const { exitCode: secondExit } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
expect(secondExit).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template integration", () => {
|
||||
test("end-to-end workflow: set→get→list→delete", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Integration test template";
|
||||
|
||||
// Set
|
||||
const { exitCode: setExit } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
content,
|
||||
);
|
||||
expect(setExit).toBe(0);
|
||||
|
||||
// Get
|
||||
const { stdout: getOut, exitCode: getExit } = await runCli(
|
||||
"template",
|
||||
"get",
|
||||
stringHash,
|
||||
);
|
||||
expect(getExit).toBe(0);
|
||||
expect(getOut).toBe(content);
|
||||
|
||||
// List
|
||||
const { stdout: listOut, exitCode: listExit } = await runCli(
|
||||
"template",
|
||||
"list",
|
||||
);
|
||||
expect(listExit).toBe(0);
|
||||
const listData = JSON.parse(listOut);
|
||||
expect(listData.length).toBeGreaterThan(0);
|
||||
|
||||
// Delete
|
||||
const { exitCode: delExit } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
expect(delExit).toBe(0);
|
||||
|
||||
// Verify deleted
|
||||
const { exitCode: finalGet } = await runCli("template", "get", stringHash);
|
||||
expect(finalGet).toBe(1);
|
||||
});
|
||||
|
||||
test("templates compatible with generic var commands", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Set via template command
|
||||
await runCli("template", "set", stringHash, "--inline", "Content");
|
||||
|
||||
// List via var command - should see template variable
|
||||
const { stdout } = await runCli("var", "list", "@ucas/template/text/");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("multiple templates for different schemas", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create templates for different schemas
|
||||
await runCli("template", "set", stringHash, "--inline", "Template 1");
|
||||
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
|
||||
await runCli("template", "set", "SCHEMA_HASH_3", "--inline", "Template 3");
|
||||
|
||||
// List should show all
|
||||
const { stdout } = await runCli("template", "list");
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template error handling", () => {
|
||||
test("unknown template subcommand", async () => {
|
||||
const { stderr, exitCode } = await runCli("template", "unknown");
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Unknown");
|
||||
});
|
||||
|
||||
test("missing schema hash argument", async () => {
|
||||
const { stderr, exitCode } = await runCli("template", "set");
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Usage:");
|
||||
});
|
||||
});
|
||||
@@ -90,8 +90,7 @@ async function createTestNode(
|
||||
* Get bootstrap type hash
|
||||
*/
|
||||
async function getBootstrapHash(store: Store): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
return builtinSchemas["@schema"] ?? "";
|
||||
return await bootstrap(store);
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { createFsStore, openStore } from "./store.js";
|
||||
export { createFsStore } from "./store.js";
|
||||
|
||||
@@ -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-"));
|
||||
@@ -49,8 +43,7 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
|
||||
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
const hash = await bootstrap(store);
|
||||
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
@@ -64,8 +57,8 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
const h1 = await bootstrap(store);
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
|
||||
expect(h1).toBe(h2);
|
||||
expect(store.listByType(h1)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,8 +104,7 @@ describe("createFsStore – persistence round-trip", () => {
|
||||
|
||||
test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
const hash = await bootstrap(store1);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const node = store2.get(hash) as CasNode;
|
||||
@@ -259,11 +251,10 @@ describe("createFsStore – listByType", () => {
|
||||
|
||||
test("bootstrap node is listed under its self type after reload", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
const hash = await bootstrap(store1);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listByType(hash)).toContain(hash);
|
||||
expect(store2.listByType(hash)).toEqual([hash]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,8 +284,7 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
|
||||
|
||||
test("verify passes on a disk-loaded bootstrap node", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
const hash = await bootstrap(store1);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const node = store2.get(hash) as CasNode;
|
||||
@@ -318,114 +308,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
"liquidjs": "^10.27.0",
|
||||
"xxhash-wasm": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
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 () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
// Should return object with 6 primitive + 18 output aliases = 24
|
||||
expect(builtinSchemas).toHaveProperty("@schema");
|
||||
expect(builtinSchemas).toHaveProperty("@string");
|
||||
expect(builtinSchemas).toHaveProperty("@number");
|
||||
expect(builtinSchemas).toHaveProperty("@object");
|
||||
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");
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("should register @schema as meta-schema alias", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) throw new Error("@schema not found");
|
||||
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
expect(metaSchema).not.toBeNull();
|
||||
expect(metaSchema?.type).toBe("object");
|
||||
expect(metaSchema?.description).toBe("json-cas JSON Schema meta-schema");
|
||||
});
|
||||
|
||||
test("should register @string schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const stringHash = builtinSchemas["@string"];
|
||||
if (!stringHash) throw new Error("@string not found");
|
||||
|
||||
const stringSchema = getSchema(store, stringHash);
|
||||
expect(stringSchema).toEqual({ type: "string" });
|
||||
});
|
||||
|
||||
test("should register @number schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const numberHash = builtinSchemas["@number"];
|
||||
if (!numberHash) throw new Error("@number not found");
|
||||
|
||||
const numberSchema = getSchema(store, numberHash);
|
||||
expect(numberSchema).toEqual({ type: "number" });
|
||||
});
|
||||
|
||||
test("should register @object schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const objectHash = builtinSchemas["@object"];
|
||||
if (!objectHash) throw new Error("@object not found");
|
||||
|
||||
const objectSchema = getSchema(store, objectHash);
|
||||
expect(objectSchema).toEqual({ type: "object" });
|
||||
});
|
||||
|
||||
test("should register @array schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const arrayHash = builtinSchemas["@array"];
|
||||
if (!arrayHash) throw new Error("@array not found");
|
||||
|
||||
const arraySchema = getSchema(store, arrayHash);
|
||||
expect(arraySchema).toEqual({ type: "array" });
|
||||
});
|
||||
|
||||
test("should register @bool schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const boolHash = builtinSchemas["@bool"];
|
||||
if (!boolHash) throw new Error("@bool not found");
|
||||
|
||||
const boolSchema = getSchema(store, boolHash);
|
||||
expect(boolSchema).toEqual({ type: "boolean" });
|
||||
});
|
||||
|
||||
test("should return same hashes on repeated bootstrap calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const first = await bootstrap(store);
|
||||
const second = await bootstrap(store);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
|
||||
// Verify each alias points to same hash
|
||||
expect(first["@string"]).toBe(second["@string"]);
|
||||
expect(first["@number"]).toBe(second["@number"]);
|
||||
expect(first["@object"]).toBe(second["@object"]);
|
||||
expect(first["@array"]).toBe(second["@array"]);
|
||||
expect(first["@bool"]).toBe(second["@bool"]);
|
||||
expect(first["@schema"]).toBe(second["@schema"]);
|
||||
});
|
||||
|
||||
test("all built-in schemas should be typed by meta-schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) throw new Error("@schema not found");
|
||||
|
||||
for (const [alias, hash] of Object.entries(builtinSchemas)) {
|
||||
if (alias === "@schema") continue; // meta-schema is self-typed
|
||||
|
||||
const node = store.get(hash);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.type).toBe(metaHash);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
@@ -63,198 +63,14 @@ const BOOTSTRAP_PAYLOAD = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const VARIABLE_PROPERTIES = {
|
||||
name: { type: "string" },
|
||||
schema: { type: "string", format: "cas_ref" },
|
||||
value: { type: "string", format: "cas_ref" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
tags: { type: "object" },
|
||||
labels: { type: "array", items: { type: "string" } },
|
||||
} as const;
|
||||
|
||||
const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
readonly [alias: string, schema: Record<string, unknown>]
|
||||
> = [
|
||||
[
|
||||
"@output/put",
|
||||
{ type: "string", format: "cas_ref", title: "ucas put result" },
|
||||
],
|
||||
[
|
||||
"@output/get",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", format: "cas_ref" },
|
||||
payload: {},
|
||||
timestamp: { type: "number" },
|
||||
},
|
||||
title: "ucas get result",
|
||||
},
|
||||
],
|
||||
["@output/has", { type: "boolean", title: "ucas has result" }],
|
||||
[
|
||||
"@output/hash",
|
||||
{ type: "string", format: "cas_ref", title: "ucas hash result" },
|
||||
],
|
||||
[
|
||||
"@output/verify",
|
||||
{
|
||||
type: "string",
|
||||
enum: ["ok", "corrupted", "invalid"],
|
||||
title: "ucas verify result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/refs",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
title: "ucas refs result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/walk",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
title: "ucas walk result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/list",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
title: "ucas list result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-set",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ucas var set result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-get",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ucas var get result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-delete",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ucas var delete result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-tag",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ucas var tag result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/var-list",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "object", properties: { ...VARIABLE_PROPERTIES } },
|
||||
title: "ucas var list result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/template-set",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
schemaHash: { type: "string", format: "cas_ref" },
|
||||
contentHash: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
title: "ucas template set result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/template-get",
|
||||
{ type: "string", title: "ucas template get result" },
|
||||
],
|
||||
[
|
||||
"@output/template-list",
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
schemaHash: { type: "string", format: "cas_ref" },
|
||||
contentHash: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
title: "ucas template list result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/template-delete",
|
||||
{
|
||||
type: "object",
|
||||
properties: { deleted: { type: "boolean" } },
|
||||
title: "ucas template delete result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@output/gc",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
total: { type: "number" },
|
||||
reachable: { type: "number" },
|
||||
collected: { type: "number" },
|
||||
scanned: { type: "number" },
|
||||
},
|
||||
title: "ucas gc result",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Write the meta-schema seed node into the store and register built-in schemas.
|
||||
* The returned object contains aliases for the meta-schema, 5 primitive schemas,
|
||||
* and 18 @output/* schemas (24 total).
|
||||
* Idempotent: calling bootstrap multiple times returns the same hashes.
|
||||
* Write the meta-schema seed node into the store.
|
||||
* The returned hash equals the node's own type field (self-referencing).
|
||||
* Idempotent: calling bootstrap multiple times returns the same hash.
|
||||
*/
|
||||
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
||||
export async function bootstrap(store: Store): Promise<Hash> {
|
||||
if (!isBootstrapCapableStore(store)) {
|
||||
throw new Error("Store does not support bootstrap");
|
||||
}
|
||||
|
||||
// 1. Bootstrap the meta-schema (self-referential)
|
||||
const metaHash = await store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
||||
|
||||
// 2. Register built-in primitive schemas directly (without putSchema to avoid recursion)
|
||||
const stringHash = await store.put(metaHash, { type: "string" });
|
||||
const numberHash = await store.put(metaHash, { type: "number" });
|
||||
const objectHash = await store.put(metaHash, { type: "object" });
|
||||
const arrayHash = await store.put(metaHash, { type: "array" });
|
||||
const boolHash = await store.put(metaHash, { type: "boolean" });
|
||||
|
||||
// 3. Register @output/* schemas
|
||||
const aliases: Record<string, Hash> = {
|
||||
"@schema": metaHash,
|
||||
"@string": stringHash,
|
||||
"@number": numberHash,
|
||||
"@object": objectHash,
|
||||
"@array": arrayHash,
|
||||
"@bool": boolHash,
|
||||
};
|
||||
|
||||
for (const [alias, schema] of OUTPUT_SCHEMAS) {
|
||||
aliases[alias] = await store.put(metaHash, schema);
|
||||
}
|
||||
|
||||
return aliases;
|
||||
return store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
||||
}
|
||||
|
||||
@@ -197,17 +197,9 @@ describe("createMemoryStore – listByType", () => {
|
||||
|
||||
test("bootstrap node is listed under its self type", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
const hash = await bootstrap(store);
|
||||
|
||||
// All built-in schemas should be typed by the meta-schema
|
||||
const allTypedByMeta = store.listByType(hash);
|
||||
expect(allTypedByMeta).toContain(hash); // meta-schema itself
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@string"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@number"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@object"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@array"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@bool"] ?? "");
|
||||
expect(store.listByType(hash)).toEqual([hash]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,61 +256,44 @@ describe("bootstrap", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a map with 24 built-in schema aliases", async () => {
|
||||
test("returns a valid 13-char hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
expect(builtinSchemas).toHaveProperty("@schema");
|
||||
expect(builtinSchemas).toHaveProperty("@string");
|
||||
expect(builtinSchemas).toHaveProperty("@number");
|
||||
expect(builtinSchemas).toHaveProperty("@object");
|
||||
expect(builtinSchemas).toHaveProperty("@array");
|
||||
expect(builtinSchemas).toHaveProperty("@bool");
|
||||
|
||||
// All values should be valid hashes
|
||||
for (const hash of Object.values(builtinSchemas)) {
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(24);
|
||||
const hash = await bootstrap(store);
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("meta-schema node is stored and retrievable", async () => {
|
||||
test("node is stored and retrievable", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const hash = await bootstrap(store);
|
||||
|
||||
expect(store.has(metaHash)).toBe(true);
|
||||
const node = store.get(metaHash);
|
||||
expect(store.has(hash)).toBe(true);
|
||||
const node = store.get(hash);
|
||||
expect(node).not.toBeNull();
|
||||
});
|
||||
|
||||
test("meta-schema node is self-referencing: type === hash", async () => {
|
||||
test("node is self-referencing: type === hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const node = store.get(metaHash) as CasNode;
|
||||
const hash = await bootstrap(store);
|
||||
const node = store.get(hash) as CasNode;
|
||||
|
||||
expect(node.type).toBe(metaHash);
|
||||
expect(node.type).toBe(hash);
|
||||
});
|
||||
|
||||
test("bootstrap node passes verify()", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const node = store.get(metaHash) as CasNode;
|
||||
const hash = await bootstrap(store);
|
||||
const node = store.get(hash) as CasNode;
|
||||
|
||||
expect(await verify(metaHash, node)).toBe(true);
|
||||
expect(await verify(hash, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("bootstrap is idempotent: same hashes on repeated calls", async () => {
|
||||
test("bootstrap is idempotent: same hash on repeated calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const h1 = await bootstrap(store);
|
||||
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);
|
||||
expect(h1).toBe(h2);
|
||||
expect(store.listByType(h1)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,7 @@ export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
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,
|
||||
renderAsync,
|
||||
renderDirect,
|
||||
} from "./render.js";
|
||||
export { type RenderOptions, render } from "./render.js";
|
||||
export type { JSONSchema } from "./schema.js";
|
||||
export {
|
||||
getSchema,
|
||||
@@ -35,4 +28,3 @@ export {
|
||||
VariableStore,
|
||||
} from "./variable-store.js";
|
||||
export { verify } from "./verify.js";
|
||||
export { wrapEnvelope } from "./wrap-envelope.js";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,285 +0,0 @@
|
||||
import { type Context, Liquid, type TagToken } from "liquidjs";
|
||||
import type { RenderOptions } from "./render.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
|
||||
const DEFAULT_RESOLUTION = 1.0;
|
||||
const DEFAULT_DECAY = 0.5;
|
||||
const DEFAULT_EPSILON = 0.01;
|
||||
const FLOAT_TOLERANCE = 1e-10;
|
||||
|
||||
/**
|
||||
* Render a CAS node using LiquidJS templates with resolution-based decay.
|
||||
* Templates are discovered via variables: @ucas/template/text/<type-hash>
|
||||
*/
|
||||
export async function renderWithTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): Promise<string> {
|
||||
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
|
||||
const decay = options?.decay ?? DEFAULT_DECAY;
|
||||
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
|
||||
|
||||
// Validate parameters
|
||||
if (resolution < 0 || resolution > 1) {
|
||||
throw new Error("resolution must be in [0, 1]");
|
||||
}
|
||||
if (decay <= 0 || decay > 1) {
|
||||
throw new Error("decay must be in (0, 1]");
|
||||
}
|
||||
if (epsilon < 0) {
|
||||
throw new Error("epsilon must be >= 0");
|
||||
}
|
||||
|
||||
const visited = new Set<Hash>();
|
||||
|
||||
// Create Liquid engine
|
||||
const engine = createLiquidEngine(store, varStore, decay);
|
||||
|
||||
return await renderNode(
|
||||
engine,
|
||||
store,
|
||||
varStore,
|
||||
hash,
|
||||
resolution,
|
||||
epsilon,
|
||||
visited,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Liquid engine instance with custom render tag
|
||||
*/
|
||||
function createLiquidEngine(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
globalDecay: number,
|
||||
): Liquid {
|
||||
const engine = new Liquid({
|
||||
strictFilters: false,
|
||||
strictVariables: false,
|
||||
});
|
||||
|
||||
// Type for storing parsed tag data
|
||||
type RenderTagState = {
|
||||
variable: string;
|
||||
decay: number | undefined;
|
||||
};
|
||||
|
||||
// Register custom {% render %} tag
|
||||
// Capture store, varStore, globalDecay in closure
|
||||
engine.registerTag("render", {
|
||||
parse(token: TagToken) {
|
||||
// Parse "variable" or "variable, decay: 0.7" syntax
|
||||
const args = token.args.trim();
|
||||
const match = args.match(/^(\S+)(?:,\s*decay:\s*([\d.]+))?$/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid render tag syntax: ${args}. Expected: {% render variable %} or {% render variable, decay: 0.7 %}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Store parsed values on the tag instance
|
||||
const state = this as unknown as RenderTagState;
|
||||
state.variable = match[1] as string;
|
||||
state.decay = match[2] ? Number.parseFloat(match[2]) : undefined;
|
||||
|
||||
// Validate decay if provided
|
||||
if (state.decay !== undefined) {
|
||||
if (state.decay <= 0 || state.decay > 1) {
|
||||
throw new Error("decay must be in (0, 1]");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async render(ctxLiquid: Context) {
|
||||
// Access parsed values
|
||||
const state = this as unknown as RenderTagState;
|
||||
const variable = state.variable;
|
||||
const explicitDecay = state.decay;
|
||||
|
||||
// Resolve the variable to a hash (split on dots for nested paths)
|
||||
const variablePath = variable.split(".");
|
||||
const value = ctxLiquid.get(variablePath);
|
||||
|
||||
// Handle null/undefined - render as empty
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle non-string values - render as empty
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const nodeHash = value as Hash;
|
||||
|
||||
// Get current render context
|
||||
const currentResolution = ctxLiquid.get(["resolution"]) as number;
|
||||
const currentEpsilon = ctxLiquid.get(["epsilon"]) as number;
|
||||
|
||||
// Compute child resolution using decay priority:
|
||||
// 1. Template explicit decay (explicitDecay)
|
||||
// 2. Global decay (from CLI/options)
|
||||
// 3. Engine default (0.5)
|
||||
const effectiveDecay =
|
||||
explicitDecay !== undefined
|
||||
? explicitDecay
|
||||
: (globalDecay ?? DEFAULT_DECAY);
|
||||
const childResolution = currentResolution * effectiveDecay;
|
||||
|
||||
// Recursively render the referenced node
|
||||
const visited = ctxLiquid.get(["__visited"]) as Set<Hash>;
|
||||
const output = await renderNode(
|
||||
engine,
|
||||
store,
|
||||
varStore,
|
||||
nodeHash,
|
||||
childResolution,
|
||||
currentEpsilon,
|
||||
visited,
|
||||
);
|
||||
|
||||
return output;
|
||||
},
|
||||
});
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single node with template or fallback to cas: reference
|
||||
*/
|
||||
async function renderNode(
|
||||
engine: Liquid,
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
hash: Hash,
|
||||
currentResolution: number,
|
||||
epsilon: number,
|
||||
visited: Set<Hash>,
|
||||
): Promise<string> {
|
||||
// Check if resolution is below threshold
|
||||
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
|
||||
// Fetch the node
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
|
||||
// Cycle detection
|
||||
if (visited.has(hash)) {
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
visited.add(hash);
|
||||
|
||||
try {
|
||||
// Try to find a template for this node's type
|
||||
const template = await findTemplate(store, varStore, node.type);
|
||||
|
||||
if (template === null) {
|
||||
// No template found - this is handled by the caller (fallback to YAML)
|
||||
// For now, return a simple representation
|
||||
visited.delete(hash);
|
||||
return renderFallback(store, node.payload);
|
||||
}
|
||||
|
||||
// Render using the template
|
||||
const context = {
|
||||
resolution: currentResolution,
|
||||
epsilon,
|
||||
hash,
|
||||
payload: node.payload,
|
||||
type: node.type,
|
||||
timestamp: node.timestamp,
|
||||
__visited: visited, // Pass visited set through context
|
||||
};
|
||||
|
||||
const output = await engine.parseAndRender(template, context);
|
||||
|
||||
visited.delete(hash);
|
||||
return output;
|
||||
} catch (error) {
|
||||
visited.delete(hash);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a template for a given type hash
|
||||
*/
|
||||
async function findTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
typeHash: Hash,
|
||||
): Promise<string | null> {
|
||||
const varName = `@ucas/template/text/${typeHash}`;
|
||||
|
||||
try {
|
||||
// Find the string schema hash (we need this to query variables)
|
||||
const stringSchema = await putSchema(store, { type: "string" });
|
||||
|
||||
const variable = varStore.get(varName, stringSchema);
|
||||
if (variable === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templateNode = store.get(variable.value);
|
||||
if (templateNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Template should be a string
|
||||
if (typeof templateNode.payload !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return templateNode.payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback renderer for nodes without templates
|
||||
*/
|
||||
function renderFallback(_store: Store, payload: unknown): string {
|
||||
// Simple YAML-like representation
|
||||
if (payload === null) {
|
||||
return "null\n";
|
||||
}
|
||||
|
||||
if (typeof payload === "string") {
|
||||
return `${payload}\n`;
|
||||
}
|
||||
|
||||
if (typeof payload === "number" || typeof payload === "boolean") {
|
||||
return `${payload}\n`;
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
if (payload.length === 0) {
|
||||
return "[]\n";
|
||||
}
|
||||
return `- ${payload.join("\n- ")}\n`;
|
||||
}
|
||||
|
||||
if (typeof payload === "object") {
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) {
|
||||
return "{}\n";
|
||||
}
|
||||
const pairs = keys.map((key) => `${key}: ${obj[key]}`);
|
||||
return `${pairs.join("\n")}\n`;
|
||||
}
|
||||
|
||||
return "null\n";
|
||||
}
|
||||
@@ -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 }}");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } 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();
|
||||
@@ -936,217 +933,3 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
expect(output).toContain("🌍");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 9: renderDirect (in-memory rendering)", () => {
|
||||
test("9.1 Render primitive value without store", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, "hello world", null, null);
|
||||
expect(output.trim()).toBe("hello world");
|
||||
});
|
||||
|
||||
test("9.2 Render object value without store", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(
|
||||
fakeTypeHash,
|
||||
{
|
||||
name: "Alice",
|
||||
age: 30,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(output).toContain("name: Alice");
|
||||
expect(output).toContain("age: 30");
|
||||
});
|
||||
|
||||
test("9.3 Render array value without store", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, ["a", "b", "c"], null, null);
|
||||
expect(output).toContain("-");
|
||||
expect(output).toContain("a");
|
||||
expect(output).toContain("b");
|
||||
expect(output).toContain("c");
|
||||
});
|
||||
|
||||
test("9.4 Render nested object without store", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(
|
||||
fakeTypeHash,
|
||||
{
|
||||
user: { name: "Bob", role: "admin" },
|
||||
active: true,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(output).toContain("name: Bob");
|
||||
expect(output).toContain("role: admin");
|
||||
expect(output).toContain("active: true");
|
||||
});
|
||||
|
||||
test("9.5 Render with store expands cas_ref fields", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Create a child node
|
||||
const childSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { msg: { type: "string" } },
|
||||
});
|
||||
const childHash = await store.put(childSchema, { msg: "inner" });
|
||||
|
||||
// Parent schema with cas_ref
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
child: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
|
||||
// Render directly with store — cas_ref should expand
|
||||
const output = renderDirect(
|
||||
parentSchema,
|
||||
{ child: childHash },
|
||||
store,
|
||||
null,
|
||||
);
|
||||
expect(output).toContain("msg: inner");
|
||||
});
|
||||
|
||||
test("9.6 Render with resolution/decay options", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, { key: "value" }, null, {
|
||||
resolution: 0.5,
|
||||
decay: 0.8,
|
||||
});
|
||||
expect(output).toContain("key: value");
|
||||
});
|
||||
|
||||
test("9.7 Validate parameters", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
expect(() =>
|
||||
renderDirect(fakeTypeHash, "x", null, { resolution: 2 }),
|
||||
).toThrow("resolution must be in [0, 1]");
|
||||
expect(() => renderDirect(fakeTypeHash, "x", null, { decay: 0 })).toThrow(
|
||||
"decay must be in (0, 1]",
|
||||
);
|
||||
expect(() =>
|
||||
renderDirect(fakeTypeHash, "x", null, { epsilon: -1 }),
|
||||
).toThrow("epsilon must be >= 0");
|
||||
});
|
||||
|
||||
test("9.8 Render null value", () => {
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, null, null, null);
|
||||
expect(output.trim()).toBe("null");
|
||||
});
|
||||
|
||||
test("9.9 cas_ref without store renders as cas: reference", () => {
|
||||
// Without store, can't identify cas_ref fields — hash strings stay as strings
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const someHash = "ABCDEFGH12345" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, { ref: someHash }, null, null);
|
||||
// Without store, it's just a string value
|
||||
expect(output).toContain(`ref: ${someHash}`);
|
||||
});
|
||||
|
||||
test("9.10 store present but schema missing — renders without ref expansion", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const unknownType = "ZZZZZZZZZZZZ0" as Hash;
|
||||
const output = renderDirect(unknownType, { key: "val" }, store, null);
|
||||
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:");
|
||||
});
|
||||
});
|
||||
|
||||
+14
-136
@@ -1,14 +1,10 @@
|
||||
import { renderWithTemplate } from "./liquid-render.js";
|
||||
import { collectRefs, getSchema, putSchema, refs } from "./schema.js";
|
||||
import { 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
|
||||
decay?: number; // (0, 1], default 0.5
|
||||
epsilon?: number; // >= 0, default 0.01
|
||||
varStore?: VariableStore; // Optional: for template lookup
|
||||
};
|
||||
|
||||
const DEFAULT_RESOLUTION = 1.0;
|
||||
@@ -18,18 +14,19 @@ const DEFAULT_EPSILON = 0.01;
|
||||
const FLOAT_TOLERANCE = 1e-10;
|
||||
|
||||
/**
|
||||
* Extract and validate resolution/decay/epsilon from options.
|
||||
* Render a CAS node as YAML with resolution-based decay.
|
||||
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
|
||||
*/
|
||||
function validateAndExtractOptions(
|
||||
options:
|
||||
| Pick<RenderOptions, "resolution" | "decay" | "epsilon">
|
||||
| null
|
||||
| undefined,
|
||||
): { resolution: number; decay: number; epsilon: number } {
|
||||
export function render(
|
||||
store: Store,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): string {
|
||||
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
|
||||
const decay = options?.decay ?? DEFAULT_DECAY;
|
||||
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
|
||||
|
||||
// Validate parameters
|
||||
if (resolution < 0 || resolution > 1) {
|
||||
throw new Error("resolution must be in [0, 1]");
|
||||
}
|
||||
@@ -40,132 +37,13 @@ function validateAndExtractOptions(
|
||||
throw new Error("epsilon must be >= 0");
|
||||
}
|
||||
|
||||
return { resolution, decay, epsilon };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a CAS node as YAML with resolution-based decay.
|
||||
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
|
||||
* This is the synchronous version without template support.
|
||||
* For template support, use renderAsync() with varStore.
|
||||
*/
|
||||
export function render(
|
||||
store: Store,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async render with LiquidJS template support.
|
||||
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
|
||||
* If varStore is provided, attempts to use LiquidJS templates first, fallback to YAML.
|
||||
*/
|
||||
export async function renderAsync(
|
||||
store: Store,
|
||||
hash: Hash,
|
||||
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
|
||||
if (varStore !== undefined) {
|
||||
try {
|
||||
const node = store.get(hash);
|
||||
if (node !== null) {
|
||||
// Check if a template exists for this type
|
||||
const templateExists = await hasTemplate(store, varStore, node.type);
|
||||
if (templateExists) {
|
||||
return await renderWithTemplate(store, varStore, hash, {
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to YAML rendering
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to YAML rendering
|
||||
const visited = new Set<Hash>();
|
||||
return renderNode(store, hash, resolution, decay, epsilon, visited);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a value directly (in-memory) without requiring it to be stored.
|
||||
* Accepts a raw { type, value } pair. Store is optional and read-only —
|
||||
* used only for schema lookup and expanding nested cas_ref references.
|
||||
* No data is written to the store.
|
||||
*/
|
||||
export function renderDirect(
|
||||
typeHash: Hash,
|
||||
value: unknown,
|
||||
store: Store | null,
|
||||
options: Omit<RenderOptions, "varStore"> | null,
|
||||
): string {
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
// Try to get schema from store to identify cas_ref fields
|
||||
let refSet = new Set<Hash>();
|
||||
if (store !== null) {
|
||||
const schema = getSchema(store, typeHash);
|
||||
if (schema !== null) {
|
||||
refSet = new Set(collectRefs(schema, value));
|
||||
}
|
||||
}
|
||||
|
||||
const childResolution = resolution * decay;
|
||||
const visited = new Set<Hash>();
|
||||
|
||||
return renderValue(
|
||||
store ?? null,
|
||||
value,
|
||||
refSet,
|
||||
childResolution,
|
||||
decay,
|
||||
epsilon,
|
||||
visited,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template exists for a given type
|
||||
*/
|
||||
async function hasTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
typeHash: Hash,
|
||||
): Promise<boolean> {
|
||||
const varName = `@ucas/template/text/${typeHash}`;
|
||||
try {
|
||||
const stringSchema = await putSchema(store, { type: "string" });
|
||||
const variable = varStore.get(varName, stringSchema);
|
||||
return variable !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNode(
|
||||
store: Store | null,
|
||||
store: Store,
|
||||
hash: Hash,
|
||||
currentResolution: number,
|
||||
decay: number,
|
||||
@@ -178,7 +56,7 @@ function renderNode(
|
||||
}
|
||||
|
||||
// Fetch the node
|
||||
const node = store !== null ? store.get(hash) : null;
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
// Missing node - render as cas: reference
|
||||
return `cas:${hash}`;
|
||||
@@ -191,7 +69,7 @@ function renderNode(
|
||||
visited.add(hash);
|
||||
|
||||
// Get references from this node's schema
|
||||
const nodeRefs = store !== null ? refs(store, node) : [];
|
||||
const nodeRefs = refs(store, node);
|
||||
const refSet = new Set(nodeRefs);
|
||||
|
||||
// Calculate child resolution for next level
|
||||
@@ -214,7 +92,7 @@ function renderNode(
|
||||
}
|
||||
|
||||
function renderValue(
|
||||
store: Store | null,
|
||||
store: Store,
|
||||
value: unknown,
|
||||
refHashes: Set<Hash>,
|
||||
childResolution: number,
|
||||
|
||||
@@ -29,8 +29,7 @@ describe("putSchema", () => {
|
||||
|
||||
test("schema node type equals the meta-schema hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const node = store.get(schemaHash) as CasNode;
|
||||
|
||||
@@ -356,8 +355,7 @@ describe("walk", () => {
|
||||
describe("bootstrap meta-schema self-reference", () => {
|
||||
test("metaNode.type === metaHash (self-referencing)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaNode = store.get(metaHash) as CasNode;
|
||||
|
||||
expect(metaNode.type).toBe(metaHash);
|
||||
@@ -365,8 +363,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("schema nodes have type === metaHash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const schemaNode = store.get(schemaHash) as CasNode;
|
||||
|
||||
@@ -375,8 +372,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("data nodes have type === schemaHash (not metaHash)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { val: { type: "number" } },
|
||||
@@ -390,8 +386,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("bootstrap is idempotent across putSchema calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
|
||||
await putSchema(store, { type: "string" });
|
||||
await putSchema(store, { type: "number" });
|
||||
|
||||
@@ -142,11 +142,7 @@ export async function putSchema(
|
||||
store: Store,
|
||||
jsonSchema: JSONSchema,
|
||||
): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) {
|
||||
throw new Error("Meta-schema not found in bootstrap result");
|
||||
}
|
||||
const metaHash = await bootstrap(store);
|
||||
if (!isValidSchema(jsonSchema)) {
|
||||
throw new SchemaValidationError(
|
||||
"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema",
|
||||
@@ -186,7 +182,7 @@ export function validate(store: Store, node: CasNode): boolean {
|
||||
* Handles: direct format, anyOf (nullable refs), items (array refs),
|
||||
* properties (nested objects), and additionalProperties (record refs).
|
||||
*/
|
||||
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
const result: Hash[] = [];
|
||||
|
||||
if (schema.format === "cas_ref") {
|
||||
|
||||
@@ -1593,186 +1593,3 @@ describe("VariableStore - Tag/Label Management", () => {
|
||||
varStore.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// @ Prefix Support for Variable Names
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("VariableStore - @ Prefix Variable Names", () => {
|
||||
let store: Store;
|
||||
let dbPath: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (dbPath) {
|
||||
try {
|
||||
unlinkSync(dbPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("should accept variable name with @ prefix in first segment", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test value");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// Should succeed
|
||||
const variable = varStore.set("@ucas/test/foo", hash);
|
||||
expect(variable.name).toBe("@ucas/test/foo");
|
||||
|
||||
const retrieved = varStore.get("@ucas/test/foo", schemaHash);
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.name).toBe("@ucas/test/foo");
|
||||
expect(retrieved?.value).toBe(hash);
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should accept variable name starting with @", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "config value");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// Single segment with @
|
||||
varStore.set("@config", hash);
|
||||
const result = varStore.get("@config", schemaHash);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe("@config");
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should accept complex @ prefix paths", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// Multiple valid patterns
|
||||
const validNames = [
|
||||
"@ucas/render/template",
|
||||
"@system/config",
|
||||
"@foo.bar/baz",
|
||||
"@app-1/test_2",
|
||||
];
|
||||
|
||||
for (const name of validNames) {
|
||||
expect(() => varStore.set(name, hash)).not.toThrow();
|
||||
const retrieved = varStore.get(name, schemaHash);
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.name).toBe(name);
|
||||
}
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should reject @ in non-first segment", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// @ only allowed at start of entire name
|
||||
const invalidNames = [
|
||||
"foo/@bar", // @ in second segment
|
||||
"foo/bar/@baz", // @ in third segment
|
||||
"foo@bar", // @ within segment (not at start)
|
||||
];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError);
|
||||
}
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should reject @ followed by invalid characters", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// @ prefix must still follow segment rules after @
|
||||
const invalidNames = [
|
||||
"@", // @ alone is empty segment
|
||||
"@/foo", // empty after @
|
||||
"@foo bar", // space not allowed
|
||||
"@foo$bar", // $ not allowed
|
||||
];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError);
|
||||
}
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should still accept all previously valid names", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// All non-@ names should continue to work
|
||||
const validNames = [
|
||||
"simple",
|
||||
"with.dots",
|
||||
"with-dashes",
|
||||
"with_underscores",
|
||||
"path/to/var",
|
||||
"foo.bar/baz-qux/test_123",
|
||||
];
|
||||
|
||||
for (const name of validNames) {
|
||||
expect(() => varStore.set(name, hash)).not.toThrow();
|
||||
}
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("should still reject previously invalid names", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(schemaHash, "test");
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
const invalidNames = [
|
||||
"", // empty
|
||||
"/leading", // leading slash
|
||||
"trailing/", // trailing slash
|
||||
"double//slash", // empty segment
|
||||
"has space", // space
|
||||
"has$dollar", // special char
|
||||
];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError);
|
||||
}
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -119,7 +116,6 @@ export class VariableStore {
|
||||
|
||||
/**
|
||||
* Validate variable name format
|
||||
* @ is allowed at the start of the first segment (system-reserved)
|
||||
*/
|
||||
private validateName(name: string): void {
|
||||
// Rule 1: Cannot be empty
|
||||
@@ -143,10 +139,9 @@ export class VariableStore {
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment)
|
||||
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ and no empty segments
|
||||
const segments = name.split("/");
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i] as string;
|
||||
for (const segment of segments) {
|
||||
if (segment === "") {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
@@ -155,12 +150,10 @@ export class VariableStore {
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
// First segment can start with @, all segments can contain [a-zA-Z0-9._-]
|
||||
const regex = i === 0 ? /^@?[a-zA-Z0-9._-]+$/ : /^[a-zA-Z0-9._-]+$/;
|
||||
if (!regex.test(segment)) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
`Segment "${segment}" contains invalid characters (only ${i === 0 ? "@, " : ""}a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -15,8 +15,7 @@ import type { CasNode } from "../src/types.js";
|
||||
describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
test("1.1: Meta-schema is a valid JSON Schema", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
@@ -26,8 +25,7 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.2: Meta-schema self-validates", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
@@ -36,8 +34,7 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.3: Meta-schema defines all supported keywords", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
@@ -60,8 +57,7 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.4: Meta-schema does not include unsupported keywords", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
@@ -78,8 +74,7 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.5: Meta-schema node type equals its own hash", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
@@ -448,8 +443,7 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
test("5.1: Bootstrap hash changes (breaking change)", async () => {
|
||||
// This is a documentation test - the old hash was different
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const newMetaHash = builtinSchemas["@schema"] ?? "";
|
||||
const newMetaHash = await bootstrap(store);
|
||||
|
||||
// The new hash should be different from the old system metadata hash
|
||||
// We just verify it's a valid hash format
|
||||
@@ -591,8 +585,7 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||
test("7.1: Meta-schema allows recursive schema definitions", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user