Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb2041e29c | |||
| 318df9b67f | |||
| 8bfcdfef25 | |||
| 55144d0e4a | |||
| 8ac13e691e | |||
| 1d08c1bf4d | |||
| 0d09e3cb80 | |||
| ca4fe8c3ac | |||
| 58f57b0dd8 | |||
| edff831e87 | |||
| 054b5d9308 | |||
| bdb77fd1e3 | |||
| b7fc03fa16 | |||
| 9367a4141a | |||
| bfec74fe8e | |||
| 70af05adf5 | |||
| ceba0b5d43 | |||
| a050160bee | |||
| 515f4d32f9 | |||
| c03180e66c | |||
| 9090456ed2 | |||
| 15ffff1fd4 | |||
| d2b9dee4b9 | |||
| 547c45829f | |||
| d328fbe6e4 | |||
| 11bb6b3e32 | |||
| 0dca8a5521 | |||
| 5aad956c83 | |||
| 038c901f4b | |||
| f4cf92e128 | |||
| c8bf38cb81 | |||
| b93d7b229a | |||
| 9912013b0a | |||
| 2ed097e207 | |||
| b0d5b05457 | |||
| de20cfde53 | |||
| 9948db77ea | |||
| c60f05f650 | |||
| 314b076c05 | |||
| 664409b94a | |||
| f3f13e6f35 | |||
| 10c5c8f98e | |||
| d28003779e | |||
| 885a8e1147 | |||
| f0ffe6b234 | |||
| fc869cfc99 | |||
| 7fd2013ef2 | |||
| 0b72c9400f | |||
| eb36c16420 | |||
| 3c8b16d7b1 | |||
| 51e81c7b99 | |||
| 2932aa5980 | |||
| a0d7b67923 | |||
| 7b29fe777c | |||
| 64b8a88bdc | |||
| 4717024e9b | |||
| 1e5f4b7c46 | |||
| 0a761f5289 | |||
| 07e08e3b38 | |||
| e0af351991 | |||
| 72f85c9077 | |||
| cccfca3137 | |||
| 5f2906908c | |||
| 077eaa6f6d | |||
| 7e23d911a4 | |||
| 301b05c212 | |||
| 22fce0ac66 | |||
| fddbb1549e | |||
| 109aaab9b8 | |||
| 906a6dfd1c |
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
*.d.ts.map
|
*.d.ts.map
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
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,16 +51,19 @@ roles:
|
|||||||
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
|
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
oneOf:
|
oneOf:
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "clean" }
|
$status: { const: "clean" }
|
||||||
summary: { type: string }
|
summary: { type: string }
|
||||||
required: [$status, summary]
|
required: [$status, summary]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "findings" }
|
$status: { const: "findings" }
|
||||||
report: { type: string }
|
report: { type: string }
|
||||||
targetWorkflow: { type: string }
|
targetWorkflow: { type: string }
|
||||||
required: [$status, report, targetWorkflow]
|
required: [$status, report, targetWorkflow]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "wrong_project" }
|
$status: { const: "wrong_project" }
|
||||||
workflowName: { type: string }
|
workflowName: { type: string }
|
||||||
required: [$status, workflowName]
|
required: [$status, workflowName]
|
||||||
@@ -93,12 +96,14 @@ 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)."
|
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:
|
frontmatter:
|
||||||
oneOf:
|
oneOf:
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "ready" }
|
$status: { const: "ready" }
|
||||||
plan: { type: string }
|
plan: { type: string }
|
||||||
repoPath: { type: string }
|
repoPath: { type: string }
|
||||||
required: [$status, plan, repoPath]
|
required: [$status, plan, repoPath]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "no_action" }
|
$status: { const: "no_action" }
|
||||||
reason: { type: string }
|
reason: { type: string }
|
||||||
required: [$status, reason]
|
required: [$status, reason]
|
||||||
@@ -128,12 +133,14 @@ roles:
|
|||||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
oneOf:
|
oneOf:
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "done" }
|
$status: { const: "done" }
|
||||||
branch: { type: string }
|
branch: { type: string }
|
||||||
worktree: { type: string }
|
worktree: { type: string }
|
||||||
required: [$status, branch, worktree]
|
required: [$status, branch, worktree]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "failed" }
|
$status: { const: "failed" }
|
||||||
reason: { type: string }
|
reason: { type: string }
|
||||||
required: [$status, reason]
|
required: [$status, reason]
|
||||||
@@ -157,12 +164,14 @@ roles:
|
|||||||
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
oneOf:
|
oneOf:
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "approved" }
|
$status: { const: "approved" }
|
||||||
branch: { type: string }
|
branch: { type: string }
|
||||||
worktree: { type: string }
|
worktree: { type: string }
|
||||||
required: [$status, branch, worktree]
|
required: [$status, branch, worktree]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "rejected" }
|
$status: { const: "rejected" }
|
||||||
comments: { type: string }
|
comments: { type: string }
|
||||||
worktree: { type: string }
|
worktree: { type: string }
|
||||||
@@ -191,11 +200,13 @@ roles:
|
|||||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
oneOf:
|
oneOf:
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "committed" }
|
$status: { const: "committed" }
|
||||||
prUrl: { type: string }
|
prUrl: { type: string }
|
||||||
required: [$status, prUrl]
|
required: [$status, prUrl]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "hook_failed" }
|
$status: { const: "hook_failed" }
|
||||||
error: { type: string }
|
error: { type: string }
|
||||||
required: [$status, error]
|
required: [$status, error]
|
||||||
|
|||||||
+22
-11
@@ -26,12 +26,14 @@ roles:
|
|||||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
oneOf:
|
oneOf:
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "ready" }
|
$status: { const: "ready" }
|
||||||
plan: { type: string }
|
plan: { type: string }
|
||||||
repoPath: { type: string }
|
repoPath: { type: string }
|
||||||
required: [$status, plan, repoPath]
|
required: [$status, plan, repoPath]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "insufficient_info" }
|
$status: { const: "insufficient_info" }
|
||||||
required: [$status]
|
required: [$status]
|
||||||
developer:
|
developer:
|
||||||
@@ -67,12 +69,14 @@ roles:
|
|||||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
oneOf:
|
oneOf:
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "done" }
|
$status: { const: "done" }
|
||||||
branch: { type: string }
|
branch: { type: string }
|
||||||
worktree: { type: string }
|
worktree: { type: string }
|
||||||
required: [$status, branch, worktree]
|
required: [$status, branch, worktree]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "failed" }
|
$status: { const: "failed" }
|
||||||
reason: { type: string }
|
reason: { type: string }
|
||||||
required: [$status, reason]
|
required: [$status, reason]
|
||||||
@@ -105,12 +109,14 @@ roles:
|
|||||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
oneOf:
|
oneOf:
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "approved" }
|
$status: { const: "approved" }
|
||||||
branch: { type: string }
|
branch: { type: string }
|
||||||
worktree: { type: string }
|
worktree: { type: string }
|
||||||
required: [$status, branch, worktree]
|
required: [$status, branch, worktree]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "rejected" }
|
$status: { const: "rejected" }
|
||||||
comments: { type: string }
|
comments: { type: string }
|
||||||
worktree: { type: string }
|
worktree: { type: string }
|
||||||
@@ -133,16 +139,19 @@ roles:
|
|||||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
oneOf:
|
oneOf:
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "passed" }
|
$status: { const: "passed" }
|
||||||
branch: { type: string }
|
branch: { type: string }
|
||||||
worktree: { type: string }
|
worktree: { type: string }
|
||||||
required: [$status, branch, worktree]
|
required: [$status, branch, worktree]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "fix_code" }
|
$status: { const: "fix_code" }
|
||||||
report: { type: string }
|
report: { type: string }
|
||||||
required: [$status, report]
|
required: [$status, report]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "fix_spec" }
|
$status: { const: "fix_spec" }
|
||||||
report: { type: string }
|
report: { type: string }
|
||||||
required: [$status, report]
|
required: [$status, report]
|
||||||
@@ -169,11 +178,13 @@ roles:
|
|||||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
oneOf:
|
oneOf:
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "committed" }
|
$status: { const: "committed" }
|
||||||
prUrl: { type: string }
|
prUrl: { type: string }
|
||||||
required: [$status, prUrl]
|
required: [$status, prUrl]
|
||||||
- properties:
|
- type: object
|
||||||
|
properties:
|
||||||
$status: { const: "hook_failed" }
|
$status: { const: "hook_failed" }
|
||||||
error: { type: string }
|
error: { type: string }
|
||||||
required: [$status, error]
|
required: [$status, error]
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Use the in-memory store for tests and embedded apps, the filesystem store for pe
|
|||||||
|-------|---------|------|
|
|-------|---------|------|
|
||||||
| Core | `@uncaged/json-cas` | Hashing, schemas, stores, verify, bootstrap |
|
| Core | `@uncaged/json-cas` | Hashing, schemas, stores, verify, bootstrap |
|
||||||
| Storage | `@uncaged/json-cas-fs` | Filesystem-backed `Store` |
|
| Storage | `@uncaged/json-cas-fs` | Filesystem-backed `Store` |
|
||||||
| CLI | `@uncaged/cli-json-cas` | `json-cas` command-line tool |
|
| CLI | `@uncaged/cli-json-cas` | `ucas` command-line tool |
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Use the in-memory store for tests and embedded apps, the filesystem store for pe
|
|||||||
|---------|-------------|------|
|
|---------|-------------|------|
|
||||||
| [`@uncaged/json-cas`](packages/json-cas/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
|
| [`@uncaged/json-cas`](packages/json-cas/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
|
||||||
| [`@uncaged/json-cas-fs`](packages/json-cas-fs/README.md) | Filesystem-backed CAS store | lib |
|
| [`@uncaged/json-cas-fs`](packages/json-cas-fs/README.md) | Filesystem-backed CAS store | lib |
|
||||||
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`json-cas` binary) | cli |
|
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`ucas` binary) | cli |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -88,30 +88,62 @@ Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/
|
|||||||
|
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
Binary: `json-cas` (from `@uncaged/cli-json-cas`). Default store: `~/.uncaged/json-cas`.
|
Binary: `ucas` (from `@uncaged/cli-json-cas`; legacy alias `json-cas` is deprecated). Default store:
|
||||||
|
`~/.uncaged/json-cas`. The store is auto-created and bootstrapped on first use — there is
|
||||||
|
no `init`/`bootstrap` command, and schemas are ordinary `@schema`-typed nodes (`ucas put
|
||||||
|
@schema file.json`), so there is no `schema` subcommand.
|
||||||
|
|
||||||
|
### Envelope format
|
||||||
|
|
||||||
|
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
|
||||||
|
of the command's `@output/*` result schema and `value` is the command payload. This makes
|
||||||
|
output self-describing and pipeable: feed any envelope into `render -p` to render its
|
||||||
|
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
|
||||||
|
raw (non-envelope) text.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// ucas has <hash>
|
||||||
|
{ "type": "AYHQD2YA9G667", "value": true }
|
||||||
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
Usage: json-cas [--store <path>] [--json] <command> [args]
|
Usage: ucas [--store <path>] [--json] <command> [args]
|
||||||
|
|
||||||
Commands:
|
Commands (all emit a { type, value } envelope unless noted):
|
||||||
init Create store dir and write bootstrap seed
|
put <type-hash> <file.json> Store node (value = hash) (@output/put)
|
||||||
bootstrap Write meta-schema seed, print hash
|
get <hash> Node payload + metadata (@output/get)
|
||||||
schema put <file.json> Register schema, print type hash
|
has <hash> Existence boolean (@output/has)
|
||||||
schema get <type-hash> Print schema JSON
|
verify <hash> ok / corrupted / invalid (@output/verify)
|
||||||
schema list List all schemas (name + hash)
|
refs <hash> Direct cas_ref edges (@output/refs)
|
||||||
schema validate <hash> Validate node against its schema
|
walk <hash> [--format tree] Recursive traversal (@output/walk)
|
||||||
put <type-hash> <file.json> Store node, print hash
|
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
|
||||||
get <hash> Print node as JSON
|
render <hash> [options] Render node as text (raw output)
|
||||||
has <hash> Print true/false
|
render --pipe/-p [options] Render a piped envelope (raw output)
|
||||||
verify <hash> Verify integrity, print ok/corrupted
|
list --type <hash-or-alias> Hashes for a type (value = list) (@output/list)
|
||||||
refs <hash> List direct cas_ref edges
|
list-meta Meta-schema hashes (@output/list-meta)
|
||||||
walk <hash> [--format tree] Recursive traversal
|
list-schema All schema hashes (@output/list-schema)
|
||||||
hash <type-hash> <file.json> Compute hash without storing (dry run)
|
var set|get|delete|tag|list ... Variable CRUD (@output/var-*)
|
||||||
cat <hash> [--payload] Output node (--payload for payload only)
|
template set|get|list|delete ... Output-template CRUD (@output/template-*)
|
||||||
|
gc Garbage collection (@output/gc)
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--store <path> Store directory (default: ~/.uncaged/json-cas)
|
--store <path> Store directory (default: ~/.uncaged/json-cas)
|
||||||
--json Compact JSON output
|
--json Compact JSON output
|
||||||
|
--pipe, -p Read a { type, value } envelope from stdin for render
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pipe examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Store a node, then render the stored content (the put envelope's hash is
|
||||||
|
# a cas_ref, so render -p dereferences and renders it):
|
||||||
|
ucas put @schema ./schemas/item.json | ucas render -p
|
||||||
|
|
||||||
|
# Render garbage-collection stats:
|
||||||
|
ucas gc | ucas render -p
|
||||||
|
|
||||||
|
# List every schema, then consume the envelope's value array with jq:
|
||||||
|
ucas list --type @schema | jq -r '.value[]'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"cborg": "^4.2.3",
|
"cborg": "^4.2.3",
|
||||||
|
"liquidjs": "^10.27.0",
|
||||||
"xxhash-wasm": "^1.1.0",
|
"xxhash-wasm": "^1.1.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -141,6 +142,8 @@
|
|||||||
|
|
||||||
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
|
"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=="],
|
"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=="],
|
"dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="],
|
||||||
@@ -203,6 +206,8 @@
|
|||||||
|
|
||||||
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
|
"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=="],
|
"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=="],
|
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
# @uncaged/cli-json-cas
|
# @uncaged/cli-json-cas
|
||||||
|
|
||||||
|
## 0.6.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
|
||||||
|
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
|
||||||
|
- `openStore()` is now async and auto-bootstraps
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- 18 `@output/*` schemas registered at bootstrap
|
||||||
|
- `list --type <hash-or-alias>` command (replaces `schema list`)
|
||||||
|
- `verify` now checks both hash integrity and schema validation
|
||||||
|
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
|
||||||
|
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
|
||||||
|
- Default LiquidJS templates for all output schemas
|
||||||
|
- Pipe composition: any command output can be piped to `render -p`
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies []:
|
||||||
|
- @uncaged/json-cas@0.6.0
|
||||||
|
- @uncaged/json-cas-fs@0.6.0
|
||||||
|
|
||||||
## 0.5.3
|
## 0.5.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ CLI tool for json-cas stores.
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`@uncaged/cli-json-cas` provides the `json-cas` command for managing a filesystem-backed store: bootstrap, schema registration, node CRUD, integrity checks, reference listing, and graph walks. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
|
`@uncaged/cli-json-cas` provides the `json-cas` command (also aliased `ucas`) for managing a filesystem-backed store: node CRUD, integrity checks, reference listing, graph walks, variables, and output templates. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
|
||||||
|
|
||||||
|
The store is **auto-created and bootstrapped** on first use, so there is no `init`/`bootstrap` command. Schemas are ordinary `@schema`-typed nodes — register one with `ucas put @schema file.json` and list them with `ucas list --type @schema`; there is no dedicated `schema` subcommand.
|
||||||
|
|
||||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
|
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
|
||||||
|
|
||||||
@@ -37,48 +39,95 @@ Usage: json-cas [--store <path>] [--json] <command> [args]
|
|||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
|
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
|
||||||
| `--json` | Compact JSON output for commands that print JSON |
|
| `--var-db <path>` | Variable database path (default: `<store>/variables.db`) |
|
||||||
|
| `--json` | Compact (single-line) JSON output |
|
||||||
|
|
||||||
|
### Envelope format
|
||||||
|
|
||||||
|
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
|
||||||
|
of the command's `@output/*` result schema and `value` is the command payload. The output
|
||||||
|
is therefore self-describing and pipeable: feed any envelope into `render -p` to render its
|
||||||
|
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
|
||||||
|
raw, non-envelope text.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// json-cas has <hash>
|
||||||
|
{ "type": "AYHQD2YA9G667", "value": true }
|
||||||
|
|
||||||
|
// json-cas template set <schema-hash> --inline "Hi {{ payload.name }}"
|
||||||
|
{ "type": "9YJZ09DDAYAWR", "value": { "schemaHash": "7XX5H51CVD9H0", "contentHash": "FC8WACA792B6F" } }
|
||||||
|
```
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Envelope `value` | Result schema |
|
||||||
|---------|-------------|
|
|---------|------------------|---------------|
|
||||||
| `init` | Create store directory and write bootstrap seed; prints meta hash |
|
| `put <type-hash> <file.json>` | stored node hash (string) | `@output/put` |
|
||||||
| `bootstrap` | Write meta-schema seed into existing store; prints hash |
|
| `get <hash>` | `{ type, payload, timestamp }` | `@output/get` |
|
||||||
| `schema put <file.json>` | Register schema from file; prints type hash |
|
| `has <hash>` | boolean | `@output/has` |
|
||||||
| `schema get <type-hash>` | Print schema JSON |
|
| `verify <hash>` | `ok` / `corrupted` / `invalid` | `@output/verify` |
|
||||||
| `schema list` | List all schemas (`hash name`) |
|
| `refs <hash>` | hashes (string[]) | `@output/refs` |
|
||||||
| `schema validate <hash>` | Validate node against its schema; prints `valid` / `invalid` |
|
| `walk <hash> [--format tree]` | hashes (string[]) or tree string | `@output/walk` |
|
||||||
| `put <type-hash> <file.json>` | Store node; prints content hash |
|
| `hash <type-hash> <file.json>` | computed hash (string) | `@output/hash` |
|
||||||
| `get <hash>` | Print full node as JSON |
|
| `render <hash> [options]` | raw text (no envelope) | — |
|
||||||
| `has <hash>` | Print `true` or `false` |
|
| `render --pipe/-p [options]` | raw text from piped envelope | — |
|
||||||
| `verify <hash>` | Verify integrity; prints `ok` or `corrupted` |
|
| `list --type <hash-or-alias>` | hashes (string[]) | `@output/list` |
|
||||||
| `refs <hash>` | Print direct `cas_ref` targets (one per line) |
|
| `var set <name> <hash> [--tag ...]` | variable object | `@output/var-set` |
|
||||||
| `walk <hash>` | BFS traversal; one hash per line |
|
| `var get <name> --schema <hash>` | variable object | `@output/var-get` |
|
||||||
| `walk <hash> --format tree` | Tree-formatted traversal |
|
| `var delete <name> [--schema <hash>]` | variable or variable[] | `@output/var-delete` |
|
||||||
| `hash <type-hash> <file.json>` | Compute hash without storing |
|
| `var tag <name> --schema <hash> <ops...>` | variable object | `@output/var-tag` |
|
||||||
| `cat <hash>` | Print node JSON |
|
| `var list [prefix] [--schema <hash>] [--tag ...]` | variable[] | `@output/var-list` |
|
||||||
| `cat <hash> --payload` | Print payload only |
|
| `template set <schema-hash> <file> \| --inline <text>` | `{ schemaHash, contentHash }` | `@output/template-set` |
|
||||||
|
| `template get <schema-hash>` | template content (string) | `@output/template-get` |
|
||||||
|
| `template list` | `{ schemaHash, contentHash }[]` | `@output/template-list` |
|
||||||
|
| `template delete <schema-hash>` | `{ deleted: boolean }` | `@output/template-delete` |
|
||||||
|
| `gc` | `{ total, reachable, collected, scanned }` | `@output/gc` |
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Initialize default store at ~/.uncaged/json-cas
|
# Register a schema (schemas are plain @schema nodes) and store a payload
|
||||||
json-cas init
|
json-cas put @schema ./schemas/item.json
|
||||||
|
# → { "type": "...", "value": "0123456789ABC" } (the schema's type hash)
|
||||||
|
|
||||||
# Use a custom store path
|
json-cas put 0123456789ABC ./payloads/item.json
|
||||||
json-cas --store ./data/cas bootstrap
|
# → { "type": "...", "value": "<content-hash>" }
|
||||||
|
|
||||||
# Register a schema and store a payload
|
|
||||||
json-cas schema put ./schemas/item.json
|
|
||||||
# → prints type hash, e.g. 0123456789ABCD
|
|
||||||
|
|
||||||
json-cas put 0123456789ABCD ./payloads/item.json
|
|
||||||
# → prints content hash
|
|
||||||
|
|
||||||
json-cas get <content-hash> --json
|
json-cas get <content-hash> --json
|
||||||
json-cas verify <content-hash>
|
json-cas verify <content-hash>
|
||||||
json-cas walk <content-hash> --format tree
|
json-cas walk <content-hash> --format tree
|
||||||
|
|
||||||
|
# List every registered schema, then extract the hashes with jq
|
||||||
|
json-cas list --type @schema | jq -r '.value[]'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pipe composition
|
||||||
|
|
||||||
|
Because every command shares the `{ type, value }` envelope, output composes directly into
|
||||||
|
`render -p`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# put emits a cas_ref hash envelope; render -p dereferences and renders the node
|
||||||
|
json-cas put @schema ./schemas/item.json | json-cas render -p
|
||||||
|
|
||||||
|
# render gc statistics
|
||||||
|
json-cas gc | json-cas render -p
|
||||||
|
|
||||||
|
# render every schema referenced by a list result
|
||||||
|
json-cas list --type @schema | json-cas render -p
|
||||||
|
```
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
|
||||||
|
`template` commands manage the LiquidJS template bound to a schema (stored as a
|
||||||
|
`@ucas/template/text/<schema-hash>` variable). `render <hash>` uses the template registered
|
||||||
|
for the node's type, falling back to YAML when none exists.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bind a template to a schema, then render a node of that type
|
||||||
|
json-cas template set 0123456789ABC --inline "Item: {{ payload.name }}"
|
||||||
|
json-cas render <content-hash>
|
||||||
|
# → Item: Widget
|
||||||
```
|
```
|
||||||
|
|
||||||
## Internal Structure
|
## Internal Structure
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/cli-json-cas",
|
"name": "@uncaged/cli-json-cas",
|
||||||
"version": "0.5.3",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"json-cas": "./src/index.ts",
|
"json-cas": "./src/index.ts",
|
||||||
@@ -8,10 +8,10 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
|
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.5.3",
|
"@uncaged/json-cas": "^0.6.0",
|
||||||
"@uncaged/json-cas-fs": "^0.5.3"
|
"@uncaged/json-cas-fs": "^0.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const pkgPath = resolve(import.meta.dir, "../package.json");
|
|
||||||
|
|
||||||
describe("ucas command alias", () => {
|
|
||||||
test("T1: ucas bin entry exists in package.json", async () => {
|
|
||||||
const pkg = await Bun.file(pkgPath).json();
|
|
||||||
expect(pkg.bin.ucas).toBe("./src/index.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("T2: json-cas bin entry is preserved in package.json", async () => {
|
|
||||||
const pkg = await Bun.file(pkgPath).json();
|
|
||||||
expect(pkg.bin["json-cas"]).toBe("./src/index.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("T3: ucas command is executable and shows help", async () => {
|
|
||||||
const entrypoint = resolve(import.meta.dir, "index.ts");
|
|
||||||
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
const exitCode = await proc.exited;
|
|
||||||
const stdout = await new Response(proc.stdout).text();
|
|
||||||
expect(exitCode).toBe(0);
|
|
||||||
expect(stdout.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("T4: both commands point to the same entrypoint", async () => {
|
|
||||||
const pkg = await Bun.file(pkgPath).json();
|
|
||||||
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+566
-284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
|||||||
|
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||||
|
|
||||||
|
exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
|
||||||
|
|
||||||
|
exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: json-cas var set <name> <hash> [--tag <tag>...]"`;
|
||||||
|
|
||||||
|
exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`;
|
||||||
|
|
||||||
|
exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = `
|
||||||
|
"Usage: json-cas [--store <path>] [--json] <command> [args]
|
||||||
|
|
||||||
|
All JSON commands emit a { type, value } envelope. The type is the hash of the
|
||||||
|
command's @output/* schema (shown in parentheses); pipe any envelope into
|
||||||
|
\`render -p\` to render its value (cas_ref hashes are expanded).
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
|
||||||
|
get <hash> Print node as envelope (@output/get)
|
||||||
|
has <hash> Print envelope (value=boolean) (@output/has)
|
||||||
|
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
|
||||||
|
refs <hash> List direct cas_ref edges (@output/refs)
|
||||||
|
walk <hash> [--format tree] Recursive traversal (@output/walk)
|
||||||
|
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
|
||||||
|
render <hash> [options] Render node as text with resolution decay (raw output)
|
||||||
|
render --pipe/-p [options] Render { type, value } from stdin (raw output)
|
||||||
|
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
|
||||||
|
list-meta List meta-schema hashes (value=string[]) (@output/list-meta)
|
||||||
|
list-schema List all schema hashes (value=string[]) (@output/list-schema)
|
||||||
|
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
|
||||||
|
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
|
||||||
|
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
|
||||||
|
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@output/var-list)
|
||||||
|
var tag <name> --schema <hash> <operations...> Modify tags/labels (@output/var-tag)
|
||||||
|
template set <schema-hash> <file> | --inline <text> Set template for schema (@output/template-set)
|
||||||
|
template get <schema-hash> Get template content (value=string) (@output/template-get)
|
||||||
|
template list List all templates (@output/template-list)
|
||||||
|
template delete <schema-hash> Delete template for schema (@output/template-delete)
|
||||||
|
gc Run garbage collection (@output/gc)
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--store <path> Store directory (default: ~/.uncaged/json-cas)
|
||||||
|
--var-db <path> Variable database path (default: <store>/variables.db)
|
||||||
|
--json Compact JSON output
|
||||||
|
--schema <hash> Schema hash filter for var get/delete/tag/list
|
||||||
|
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
|
||||||
|
--inline <text> Inline text content for template set
|
||||||
|
--resolution <n> Initial resolution for render (default: 1.0)
|
||||||
|
--decay <n> Decay factor for render (default: 0.5)
|
||||||
|
--epsilon <n> Cutoff threshold for render (default: 0.01)
|
||||||
|
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||||
|
{
|
||||||
|
"type": "5KVHSESSX7N96",
|
||||||
|
"value": {
|
||||||
|
"labels": [],
|
||||||
|
"name": "myapp/config",
|
||||||
|
"schema": "8WAZV39SD724T",
|
||||||
|
"tags": {},
|
||||||
|
"value": "6KZ930XYK2MHB",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||||
|
{
|
||||||
|
"type": "7D4R2MJ1EYF08",
|
||||||
|
"value": {
|
||||||
|
"labels": [],
|
||||||
|
"name": "myapp/config",
|
||||||
|
"schema": "8WAZV39SD724T",
|
||||||
|
"tags": {},
|
||||||
|
"value": "6KZ930XYK2MHB",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||||
|
{
|
||||||
|
"type": "E8158M25YNR9M",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"labels": [],
|
||||||
|
"name": "myapp/config",
|
||||||
|
"schema": "8WAZV39SD724T",
|
||||||
|
"tags": {},
|
||||||
|
"value": "6KZ930XYK2MHB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||||
|
{
|
||||||
|
"type": "E8158M25YNR9M",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"labels": [],
|
||||||
|
"name": "myapp/config",
|
||||||
|
"schema": "8WAZV39SD724T",
|
||||||
|
"tags": {},
|
||||||
|
"value": "6KZ930XYK2MHB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
|
||||||
|
{
|
||||||
|
"type": "5KVHSESSX7N96",
|
||||||
|
"value": {
|
||||||
|
"labels": [],
|
||||||
|
"name": "myapp/config",
|
||||||
|
"schema": "8WAZV39SD724T",
|
||||||
|
"tags": {},
|
||||||
|
"value": "AE80669JYG4K2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||||
|
{
|
||||||
|
"type": "2WX4XRYSACRWR",
|
||||||
|
"value": {
|
||||||
|
"labels": [
|
||||||
|
"important",
|
||||||
|
],
|
||||||
|
"name": "myapp/config",
|
||||||
|
"schema": "8WAZV39SD724T",
|
||||||
|
"tags": {
|
||||||
|
"env": "prod",
|
||||||
|
},
|
||||||
|
"value": "6KZ930XYK2MHB",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
|
||||||
|
{
|
||||||
|
"type": "E8158M25YNR9M",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"labels": [
|
||||||
|
"important",
|
||||||
|
],
|
||||||
|
"name": "myapp/config",
|
||||||
|
"schema": "8WAZV39SD724T",
|
||||||
|
"tags": {
|
||||||
|
"env": "prod",
|
||||||
|
},
|
||||||
|
"value": "6KZ930XYK2MHB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
|
||||||
|
{
|
||||||
|
"type": "E8158M25YNR9M",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"labels": [
|
||||||
|
"important",
|
||||||
|
],
|
||||||
|
"name": "myapp/config",
|
||||||
|
"schema": "8WAZV39SD724T",
|
||||||
|
"tags": {
|
||||||
|
"env": "prod",
|
||||||
|
},
|
||||||
|
"value": "6KZ930XYK2MHB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||||
|
{
|
||||||
|
"type": "2WX4XRYSACRWR",
|
||||||
|
"value": {
|
||||||
|
"labels": [],
|
||||||
|
"name": "myapp/config",
|
||||||
|
"schema": "8WAZV39SD724T",
|
||||||
|
"tags": {
|
||||||
|
"env": "prod",
|
||||||
|
},
|
||||||
|
"value": "6KZ930XYK2MHB",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||||
|
{
|
||||||
|
"type": "F89626QEK09YY",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"labels": [],
|
||||||
|
"name": "myapp/config",
|
||||||
|
"schema": "8WAZV39SD724T",
|
||||||
|
"tags": {
|
||||||
|
"env": "prod",
|
||||||
|
},
|
||||||
|
"value": "6KZ930XYK2MHB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=8WAZV39SD724T"`;
|
||||||
|
|
||||||
|
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
|
||||||
|
{
|
||||||
|
"type": "A8RGY9B1JSCDJ",
|
||||||
|
"value": {
|
||||||
|
"contentHash": "0359SHMP7VBFD",
|
||||||
|
"schemaHash": "8WAZV39SD724T",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
|
||||||
|
{
|
||||||
|
"type": "64FP3TWKK69YH",
|
||||||
|
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
|
||||||
|
{
|
||||||
|
"type": "BE20H482GBNM3",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"contentHash": "0359SHMP7VBFD",
|
||||||
|
"schemaHash": "8WAZV39SD724T",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
|
||||||
|
{
|
||||||
|
"type": "0FXB2GS8Y9R1R",
|
||||||
|
"value": {
|
||||||
|
"deleted": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 8WAZV39SD724T"`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||||
|
|
||||||
|
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
|
||||||
|
{
|
||||||
|
"type": "CE40D09H75NFZ",
|
||||||
|
"value": {
|
||||||
|
"payload": {
|
||||||
|
"age": 30,
|
||||||
|
"name": "Alice",
|
||||||
|
},
|
||||||
|
"type": "8WAZV39SD724T",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||||
|
|
||||||
|
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
|
||||||
|
|
||||||
|
exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||||
|
|
||||||
|
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||||
|
|
||||||
|
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
|
||||||
|
{
|
||||||
|
"type": "BZ31JDDWX2AWH",
|
||||||
|
"value": "ok",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
|
||||||
|
"{
|
||||||
|
"type": "DG8SAB75PV9P7",
|
||||||
|
"value": []
|
||||||
|
}"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
|
||||||
|
"{
|
||||||
|
"type": "EVHG7Q7FK83H0",
|
||||||
|
"value": [
|
||||||
|
"6KZ930XYK2MHB"
|
||||||
|
]
|
||||||
|
}"
|
||||||
|
`;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// ---- @ Alias Resolution Tests ----
|
||||||
|
|
||||||
|
let testDir: string;
|
||||||
|
let storePath: string;
|
||||||
|
let cliPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create unique temp directory for each test
|
||||||
|
testDir = join(
|
||||||
|
tmpdir(),
|
||||||
|
`json-cas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
storePath = join(testDir, "store");
|
||||||
|
cliPath = join(import.meta.dir, "../src/index.ts");
|
||||||
|
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
mkdirSync(storePath, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up test directory
|
||||||
|
try {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run CLI command and return stdout, stderr, and exit code
|
||||||
|
*/
|
||||||
|
async function runCliAlias(...args: string[]): Promise<{
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
}> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", "run", cliPath, "--store", storePath, ...args],
|
||||||
|
{
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const [stdout, stderr] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: stdout.trim(),
|
||||||
|
stderr: stderr.trim(),
|
||||||
|
exitCode: proc.exitCode ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the `value` field from a { type, value } envelope JSON string. */
|
||||||
|
function envValue(json: string): unknown {
|
||||||
|
return (JSON.parse(json.trim()) as { value: unknown }).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("@ Alias Resolution - put", () => {
|
||||||
|
test("ucas put @string <file> should resolve alias", async () => {
|
||||||
|
await runCliAlias("init");
|
||||||
|
|
||||||
|
const payloadFile = join(testDir, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify("hello world"));
|
||||||
|
|
||||||
|
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||||
|
"put",
|
||||||
|
"@string",
|
||||||
|
payloadFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
// Should output an envelope whose value is a valid hash (13 chars)
|
||||||
|
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ucas put @number <file> should resolve alias", async () => {
|
||||||
|
await runCliAlias("init");
|
||||||
|
|
||||||
|
const payloadFile = join(testDir, "payload.json");
|
||||||
|
writeFileSync(payloadFile, "42");
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliAlias(
|
||||||
|
"put",
|
||||||
|
"@number",
|
||||||
|
payloadFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ucas put @object <file> should resolve alias", async () => {
|
||||||
|
await runCliAlias("init");
|
||||||
|
|
||||||
|
const payloadFile = join(testDir, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({ foo: "bar" }));
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliAlias(
|
||||||
|
"put",
|
||||||
|
"@object",
|
||||||
|
payloadFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ucas put @invalid <file> should fail", async () => {
|
||||||
|
await runCliAlias("init");
|
||||||
|
|
||||||
|
const payloadFile = join(testDir, "payload.json");
|
||||||
|
writeFileSync(payloadFile, "{}");
|
||||||
|
|
||||||
|
const { stderr, exitCode } = await runCliAlias(
|
||||||
|
"put",
|
||||||
|
"@invalid",
|
||||||
|
payloadFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ucas put @schema with nested type constraints should succeed", async () => {
|
||||||
|
await runCliAlias("init");
|
||||||
|
|
||||||
|
const schemaFile = join(testDir, "constrained-schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", minLength: 1, maxLength: 50 },
|
||||||
|
age: { type: "number", minimum: 0, maximum: 150 },
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
minItems: 1,
|
||||||
|
uniqueItems: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||||
|
"put",
|
||||||
|
"@schema",
|
||||||
|
schemaFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@ Alias Resolution - hash", () => {
|
||||||
|
test("ucas hash @string <file> should compute hash without storing", async () => {
|
||||||
|
await runCliAlias("init");
|
||||||
|
|
||||||
|
const payloadFile = join(testDir, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify("test"));
|
||||||
|
|
||||||
|
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||||
|
"hash",
|
||||||
|
"@string",
|
||||||
|
payloadFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { envValue, stripVolatile } from "./helpers";
|
||||||
|
|
||||||
|
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||||
|
const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||||
|
|
||||||
|
// --- ucas command alias tests (from cli.test.ts) ---
|
||||||
|
|
||||||
|
describe("ucas command alias", () => {
|
||||||
|
test("T1: ucas bin entry exists in package.json", async () => {
|
||||||
|
const pkg = await Bun.file(pkgPath).json();
|
||||||
|
expect(pkg.bin.ucas).toBe("./src/index.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T2: json-cas bin entry is preserved in package.json", async () => {
|
||||||
|
const pkg = await Bun.file(pkgPath).json();
|
||||||
|
expect(pkg.bin["json-cas"]).toBe("./src/index.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T3: ucas command is executable and shows help", async () => {
|
||||||
|
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = await new Response(proc.stdout).text();
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T4: both commands point to the same entrypoint", async () => {
|
||||||
|
const pkg = await Bun.file(pkgPath).json();
|
||||||
|
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- e2e Phase 7: Edge Cases ---
|
||||||
|
|
||||||
|
describe("Phase 7: Edge Cases", () => {
|
||||||
|
let tmpStore: string;
|
||||||
|
let varDbPath: string;
|
||||||
|
let typeHash: string;
|
||||||
|
let nodeHash: string;
|
||||||
|
|
||||||
|
async function runCli(
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||||
|
varDbPath = join(tmpStore, "variables.db");
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "test-schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
|
const store = await openFsStore(tmpStore);
|
||||||
|
typeHash = await putSchema(
|
||||||
|
store,
|
||||||
|
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
|
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||||
|
nodeHash = envValue(stdout) as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("7.1 get non-existent hash errors gracefully", async () => {
|
||||||
|
const { stderr, exitCode } = await runCli(["get", "AAAAAAAAAAAAA"]);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("7.2 put with non-existent file errors with ENOENT", async () => {
|
||||||
|
const { stderr, exitCode } = await runCli([
|
||||||
|
"put",
|
||||||
|
typeHash,
|
||||||
|
"/nonexistent/file.json",
|
||||||
|
]);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("ENOENT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("7.3 var set empty name errors", async () => {
|
||||||
|
const { stderr, exitCode } = await runCli(["var", "set", "", nodeHash]);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr.length).toBeGreaterThan(0);
|
||||||
|
expect(stderr).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("7.4 var set name with invalid chars errors", async () => {
|
||||||
|
const { stderr, exitCode } = await runCli([
|
||||||
|
"var",
|
||||||
|
"set",
|
||||||
|
"invalid name!",
|
||||||
|
nodeHash,
|
||||||
|
]);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr.length).toBeGreaterThan(0);
|
||||||
|
expect(stderr).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("7.5 no subcommand shows help text", async () => {
|
||||||
|
const { stdout, stderr, exitCode: _exitCode } = await runCli([]);
|
||||||
|
const combined = stdout + stderr;
|
||||||
|
expect(combined.length).toBeGreaterThan(0);
|
||||||
|
expect(combined).toMatchSnapshot();
|
||||||
|
expect(combined.toLowerCase()).toContain("usage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("7.6 --store path is a file errors", async () => {
|
||||||
|
const fileAsStore = join(tmpStore, "not-a-directory");
|
||||||
|
writeFileSync(fileAsStore, "test");
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"bun",
|
||||||
|
entrypoint,
|
||||||
|
"--store",
|
||||||
|
fileAsStore,
|
||||||
|
"--var-db",
|
||||||
|
varDbPath,
|
||||||
|
"get",
|
||||||
|
"AAAAAAAAAAAAA",
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("not a directory");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- e2e Phase 3: Variable System (edge cases from e2e.test.ts) ---
|
||||||
|
|
||||||
|
describe("Phase 3: Variable System", () => {
|
||||||
|
let tmpStore: string;
|
||||||
|
let varDbPath: string;
|
||||||
|
let typeHash: string;
|
||||||
|
let nodeHash: string;
|
||||||
|
|
||||||
|
async function runCli(
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||||
|
varDbPath = join(tmpStore, "variables.db");
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "test-schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
|
const store = await openFsStore(tmpStore);
|
||||||
|
typeHash = await putSchema(
|
||||||
|
store,
|
||||||
|
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
|
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||||
|
nodeHash = envValue(stdout) as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.1 var set creates variable", async () => {
|
||||||
|
const { exitCode, stdout } = await runCli([
|
||||||
|
"var",
|
||||||
|
"set",
|
||||||
|
"myapp/config",
|
||||||
|
nodeHash,
|
||||||
|
]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.2 var get returns variable", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli([
|
||||||
|
"var",
|
||||||
|
"get",
|
||||||
|
"myapp/config",
|
||||||
|
"--schema",
|
||||||
|
typeHash,
|
||||||
|
]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
expect(stdout).toContain(nodeHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.3 var list shows all variables", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["var", "list"]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
expect(stdout).toContain("myapp/config");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.4 var list prefix filters by prefix", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["var", "list", "myapp/"]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
expect(stdout).toContain("myapp/config");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.5 var set upsert updates existing variable", async () => {
|
||||||
|
const node2File = join(tmpStore, "node2.json");
|
||||||
|
writeFileSync(node2File, JSON.stringify({ name: "Bob", age: 25 }));
|
||||||
|
const { stdout: node2Out } = await runCli(["put", typeHash, node2File]);
|
||||||
|
const node2Hash = envValue(node2Out) as string;
|
||||||
|
const { exitCode, stdout } = await runCli([
|
||||||
|
"var",
|
||||||
|
"set",
|
||||||
|
"myapp/config",
|
||||||
|
node2Hash,
|
||||||
|
]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
// Restore original value
|
||||||
|
await runCli(["var", "set", "myapp/config", nodeHash]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.6 var tag adds kv tag and label", async () => {
|
||||||
|
const { exitCode, stdout } = await runCli([
|
||||||
|
"var",
|
||||||
|
"tag",
|
||||||
|
"myapp/config",
|
||||||
|
"--schema",
|
||||||
|
typeHash,
|
||||||
|
"env:prod",
|
||||||
|
"important",
|
||||||
|
]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.7 var list --tag env:prod filters by kv tag", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli([
|
||||||
|
"var",
|
||||||
|
"list",
|
||||||
|
"--tag",
|
||||||
|
"env:prod",
|
||||||
|
]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("myapp/config");
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.8 var list --tag important filters by label", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli([
|
||||||
|
"var",
|
||||||
|
"list",
|
||||||
|
"--tag",
|
||||||
|
"important",
|
||||||
|
]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("myapp/config");
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.9 var tag remove deletes label", async () => {
|
||||||
|
const { exitCode, stdout } = await runCli([
|
||||||
|
"var",
|
||||||
|
"tag",
|
||||||
|
"myapp/config",
|
||||||
|
"--schema",
|
||||||
|
typeHash,
|
||||||
|
":important",
|
||||||
|
]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
// Verify label is gone
|
||||||
|
const { stdout: listOut } = await runCli([
|
||||||
|
"var",
|
||||||
|
"list",
|
||||||
|
"--tag",
|
||||||
|
"important",
|
||||||
|
]);
|
||||||
|
expect(listOut).not.toContain("myapp/config");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.10 var delete removes variable", async () => {
|
||||||
|
const { exitCode, stdout } = await runCli([
|
||||||
|
"var",
|
||||||
|
"delete",
|
||||||
|
"myapp/config",
|
||||||
|
]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.11 var get deleted variable returns not found", async () => {
|
||||||
|
const { stderr, exitCode } = await runCli([
|
||||||
|
"var",
|
||||||
|
"get",
|
||||||
|
"myapp/config",
|
||||||
|
"--schema",
|
||||||
|
typeHash,
|
||||||
|
]);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- e2e Phase 4: Template System ---
|
||||||
|
|
||||||
|
describe("Phase 4: Template System", () => {
|
||||||
|
let tmpStore: string;
|
||||||
|
let varDbPath: string;
|
||||||
|
let typeHash: string;
|
||||||
|
|
||||||
|
async function runCli(
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||||
|
varDbPath = join(tmpStore, "variables.db");
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "test-schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
|
const store = await openFsStore(tmpStore);
|
||||||
|
typeHash = await putSchema(
|
||||||
|
store,
|
||||||
|
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4.1 template set registers template", async () => {
|
||||||
|
const tmplFile = join(tmpStore, "test.liquid");
|
||||||
|
writeFileSync(tmplFile, "Name: {{ payload.name }}, Age: {{ payload.age }}");
|
||||||
|
const { exitCode, stdout } = await runCli([
|
||||||
|
"template",
|
||||||
|
"set",
|
||||||
|
typeHash,
|
||||||
|
tmplFile,
|
||||||
|
]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4.2 template get returns template text", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toBe(
|
||||||
|
"Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||||
|
);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4.3 template list shows registered templates", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["template", "list"]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain(typeHash);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4.4 template delete removes template", async () => {
|
||||||
|
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4.5 template get deleted template returns not found", async () => {
|
||||||
|
const { stderr, exitCode } = await runCli(["template", "get", typeHash]);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { envValue } from "./helpers";
|
||||||
|
|
||||||
|
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||||
|
|
||||||
|
let tmpStore: string;
|
||||||
|
let varDbPath: string;
|
||||||
|
let typeHash: string;
|
||||||
|
let nodeHash: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||||
|
varDbPath = join(tmpStore, "variables.db");
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "test-schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
|
const store = await openFsStore(tmpStore);
|
||||||
|
typeHash = await putSchema(
|
||||||
|
store,
|
||||||
|
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
|
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||||
|
nodeHash = envValue(stdout) as string;
|
||||||
|
|
||||||
|
// Set a var referencing the node so it survives GC
|
||||||
|
await runCli(["var", "set", "gc-test/ref", nodeHash]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runCli(
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Phase 6: GC ----
|
||||||
|
|
||||||
|
describe("Phase 6: GC", () => {
|
||||||
|
test("6.1 gc runs without error", async () => {
|
||||||
|
const { exitCode, stdout } = await runCli(["gc"]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
// Assert structural shape only — exact counts depend on phase history
|
||||||
|
const result = envValue(stdout) as Record<string, unknown>;
|
||||||
|
expect(typeof result.total).toBe("number");
|
||||||
|
expect(typeof result.reachable).toBe("number");
|
||||||
|
expect(typeof result.collected).toBe("number");
|
||||||
|
expect(typeof result.scanned).toBe("number");
|
||||||
|
expect(result.total as number).toBeGreaterThanOrEqual(
|
||||||
|
result.reachable as number,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("6.2 gc | render -p renders the gc stats", async () => {
|
||||||
|
const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]);
|
||||||
|
expect(gcExit).toBe(0);
|
||||||
|
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"bun",
|
||||||
|
entrypoint,
|
||||||
|
"--store",
|
||||||
|
tmpStore,
|
||||||
|
"--var-db",
|
||||||
|
varDbPath,
|
||||||
|
"render",
|
||||||
|
"--pipe",
|
||||||
|
],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
proc.stdin.write(gcOut);
|
||||||
|
proc.stdin.end();
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
// gc value is an object { total, reachable, collected, scanned }
|
||||||
|
expect(stdout).toContain("total:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("6.3 gc preserves node referenced by a var", async () => {
|
||||||
|
const { exitCode } = await runCli(["gc"]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
const { stdout } = await runCli(["has", nodeHash]);
|
||||||
|
expect(envValue(stdout)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("6.4 gc reclaims orphan node", async () => {
|
||||||
|
const orphanFile = join(tmpStore, "orphan.json");
|
||||||
|
writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 }));
|
||||||
|
const { stdout: orphanOut } = await runCli(["put", typeHash, orphanFile]);
|
||||||
|
const orphanHash = envValue(orphanOut) as string;
|
||||||
|
|
||||||
|
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
|
||||||
|
expect(envValue(beforeGc)).toBe(true);
|
||||||
|
|
||||||
|
await runCli(["gc"]);
|
||||||
|
const { stdout: afterGc } = await runCli(["has", orphanHash]);
|
||||||
|
expect(envValue(afterGc)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import type { JSONSchema } from "@uncaged/json-cas";
|
||||||
|
import { putSchema } from "@uncaged/json-cas";
|
||||||
|
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
|
||||||
|
export {
|
||||||
|
join,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
resolve,
|
||||||
|
rmSync,
|
||||||
|
tmpdir,
|
||||||
|
writeFileSync,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||||
|
export const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||||
|
|
||||||
|
/** Extract the `value` field from a { type, value } envelope JSON string. */
|
||||||
|
export function envValue(json: string): unknown {
|
||||||
|
return (JSON.parse(json.trim()) as { value: unknown }).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a schema directly via the library (CLI schema put was removed).
|
||||||
|
* Returns the type hash.
|
||||||
|
*/
|
||||||
|
export async function putSchemaFile(
|
||||||
|
storePath: string,
|
||||||
|
schemaFilePath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const store = await openFsStore(storePath);
|
||||||
|
const schema = JSON.parse(
|
||||||
|
readFileSync(schemaFilePath, "utf-8"),
|
||||||
|
) as JSONSchema;
|
||||||
|
const hash = await putSchema(store, schema);
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run CLI command. Accepts either a string[] or ...string[] (rest args).
|
||||||
|
* If first arg is an array, uses that as args. Otherwise treats all args as the command.
|
||||||
|
*/
|
||||||
|
export async function runCli(
|
||||||
|
args: string[],
|
||||||
|
storePath?: string,
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const finalArgs = storePath
|
||||||
|
? ["bun", entrypoint, "--store", storePath, ...args]
|
||||||
|
: ["bun", entrypoint, ...args];
|
||||||
|
const proc = Bun.spawn(finalArgs, {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = await new Response(proc.stdout).text();
|
||||||
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCliWithStdin(
|
||||||
|
args: string[],
|
||||||
|
storePath: string,
|
||||||
|
stdin: string,
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const finalArgs = ["bun", entrypoint, "--store", storePath, ...args];
|
||||||
|
const proc = Bun.spawn(finalArgs, {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
stdin: "pipe",
|
||||||
|
});
|
||||||
|
proc.stdin.write(stdin);
|
||||||
|
proc.stdin.end();
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = await new Response(proc.stdout).text();
|
||||||
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JSON and strip volatile fields (timestamp, created, updated)
|
||||||
|
* so snapshots are stable across runs.
|
||||||
|
*/
|
||||||
|
export function stripVolatile(json: string): unknown {
|
||||||
|
const strip = (v: unknown): unknown => {
|
||||||
|
if (Array.isArray(v)) return v.map(strip);
|
||||||
|
if (v !== null && typeof v === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||||
|
if (k === "timestamp" || k === "created" || k === "updated") continue;
|
||||||
|
out[k] = strip(val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
return strip(JSON.parse(json));
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { BOOTSTRAP_STORE } from "@uncaged/json-cas";
|
||||||
|
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import { envValue, runCli } from "./helpers.js";
|
||||||
|
|
||||||
|
let storePath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storePath = mkdtempSync(join(tmpdir(), "json-cas-list-meta-schema-"));
|
||||||
|
mkdirSync(storePath, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(storePath, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list-meta CLI command", () => {
|
||||||
|
test("E1. list-meta on bootstrapped store contains exactly the meta-schema hash", async () => {
|
||||||
|
// First, get @schema hash by calling has on it (also triggers bootstrap)
|
||||||
|
const { stdout: hashOut, exitCode: hashCode } = await runCli(
|
||||||
|
["hash", "@schema", "--pipe"],
|
||||||
|
storePath,
|
||||||
|
);
|
||||||
|
// ensure bootstrap by running a no-op command:
|
||||||
|
void hashOut;
|
||||||
|
void hashCode;
|
||||||
|
|
||||||
|
// Bootstrap fully via 'list --type @schema'
|
||||||
|
const { stdout: schemaListOut } = await runCli(
|
||||||
|
["list", "--type", "@schema"],
|
||||||
|
storePath,
|
||||||
|
);
|
||||||
|
const schemaList = envValue(schemaListOut) as string[];
|
||||||
|
expect(Array.isArray(schemaList)).toBe(true);
|
||||||
|
|
||||||
|
const { stdout, stderr, exitCode } = await runCli(["list-meta"], storePath);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
|
||||||
|
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
expect(Array.isArray(parsed.value)).toBe(true);
|
||||||
|
expect(parsed.value).toHaveLength(1);
|
||||||
|
expect(parsed.value[0]).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("E1. --json flag yields compact JSON", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(
|
||||||
|
["--json", "list-meta"],
|
||||||
|
storePath,
|
||||||
|
);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
// compact = no newlines/spaces between fields
|
||||||
|
expect(stdout.trim()).not.toContain("\n ");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list-schema CLI command", () => {
|
||||||
|
test("E2. list-schema on bootstrapped store includes meta-schema and built-ins", async () => {
|
||||||
|
const { stdout, stderr, exitCode } = await runCli(
|
||||||
|
["list-schema"],
|
||||||
|
storePath,
|
||||||
|
);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
|
||||||
|
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
expect(Array.isArray(parsed.value)).toBe(true);
|
||||||
|
// meta + 5 primitive + 20 output = 26
|
||||||
|
expect(parsed.value.length).toBeGreaterThanOrEqual(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("usage help", () => {
|
||||||
|
test("E3. printing usage includes list-meta and list-schema lines", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli([], storePath);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("list-meta");
|
||||||
|
expect(stdout).toContain("list-schema");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("F1. output schemas registered", () => {
|
||||||
|
test("@output/list-meta and @output/list-schema schemas exist", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["list-meta"], storePath);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
const parsed = JSON.parse(stdout) as { type: string };
|
||||||
|
// type hash references the @output/list-meta schema, must be retrievable
|
||||||
|
const { stdout: getOut, exitCode: getCode } = await runCli(
|
||||||
|
["get", parsed.type],
|
||||||
|
storePath,
|
||||||
|
);
|
||||||
|
expect(getCode).toBe(0);
|
||||||
|
const node = envValue(getOut) as {
|
||||||
|
payload: { title?: string };
|
||||||
|
};
|
||||||
|
expect(node.payload.title).toBe("ucas list-meta result");
|
||||||
|
|
||||||
|
const { stdout: schemaOut } = await runCli(["list-schema"], storePath);
|
||||||
|
const schemaParsed = JSON.parse(schemaOut) as { type: string };
|
||||||
|
const { stdout: getSchOut, exitCode: getSchCode } = await runCli(
|
||||||
|
["get", schemaParsed.type],
|
||||||
|
storePath,
|
||||||
|
);
|
||||||
|
expect(getSchCode).toBe(0);
|
||||||
|
const schNode = envValue(getSchOut) as {
|
||||||
|
payload: { title?: string };
|
||||||
|
};
|
||||||
|
expect(schNode.payload.title).toBe("ucas list-schema result");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("E4. list-schema vs list --type with multiple meta-schema versions", () => {
|
||||||
|
test("list-schema includes schemas typed by older meta-schemas; list --type @schema does not", async () => {
|
||||||
|
// Set up two distinct meta-schemas via the library
|
||||||
|
const store = await openFsStore(storePath);
|
||||||
|
const m1 = await store[BOOTSTRAP_STORE]({
|
||||||
|
type: "object",
|
||||||
|
title: "meta-v1",
|
||||||
|
});
|
||||||
|
const m2 = await store[BOOTSTRAP_STORE]({
|
||||||
|
type: "object",
|
||||||
|
title: "meta-v2",
|
||||||
|
});
|
||||||
|
// schema typed by older meta M1
|
||||||
|
const sM1 = await store.put(m1, { type: "string" });
|
||||||
|
expect(m1).not.toBe(m2);
|
||||||
|
|
||||||
|
// CLI: list-schema must include sM1
|
||||||
|
const { stdout: lsOut, exitCode: lsCode } = await runCli(
|
||||||
|
["list-schema"],
|
||||||
|
storePath,
|
||||||
|
);
|
||||||
|
expect(lsCode).toBe(0);
|
||||||
|
const lsValue = envValue(lsOut) as string[];
|
||||||
|
expect(lsValue).toContain(sM1);
|
||||||
|
expect(lsValue).toContain(m1);
|
||||||
|
expect(lsValue).toContain(m2);
|
||||||
|
|
||||||
|
// CLI: list --type <M2 hash> must NOT include sM1
|
||||||
|
const { stdout: ltOut, exitCode: ltCode } = await runCli(
|
||||||
|
["list", "--type", m2],
|
||||||
|
storePath,
|
||||||
|
);
|
||||||
|
expect(ltCode).toBe(0);
|
||||||
|
const ltValue = envValue(ltOut) as string[];
|
||||||
|
expect(ltValue).not.toContain(sM1);
|
||||||
|
|
||||||
|
// list-schema.value.length > list --type <newest>.value.length
|
||||||
|
expect(lsValue.length).toBeGreaterThan(ltValue.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { envValue, stripVolatile } from "./helpers";
|
||||||
|
|
||||||
|
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||||
|
|
||||||
|
let tmpStore: string;
|
||||||
|
let varDbPath: string;
|
||||||
|
let typeHash: string;
|
||||||
|
let nodeHash: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||||
|
varDbPath = join(tmpStore, "variables.db");
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "test-schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
|
const store = await openFsStore(tmpStore);
|
||||||
|
typeHash = await putSchema(
|
||||||
|
store,
|
||||||
|
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
|
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||||
|
nodeHash = envValue(stdout) as string;
|
||||||
|
|
||||||
|
// Set up template for render tests
|
||||||
|
const tmplFile = join(tmpStore, "render-template.liquid");
|
||||||
|
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
|
||||||
|
await runCli(["template", "set", typeHash, tmplFile]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runCli(
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCliWithStdin(
|
||||||
|
args: string[],
|
||||||
|
stdin: string,
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
proc.stdin.write(stdin);
|
||||||
|
proc.stdin.end();
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Phase 8: Pipe Composition ----
|
||||||
|
|
||||||
|
describe("Phase 8: Pipe Composition", () => {
|
||||||
|
test("8.1 put | render -p expands the stored hash to its content", async () => {
|
||||||
|
const nodeFile = join(tmpStore, "pipe-node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Bob", age: 42 }));
|
||||||
|
|
||||||
|
const { stdout: putOut, exitCode: putExit } = await runCli([
|
||||||
|
"put",
|
||||||
|
typeHash,
|
||||||
|
nodeFile,
|
||||||
|
]);
|
||||||
|
expect(putExit).toBe(0);
|
||||||
|
|
||||||
|
// The put envelope value is a cas_ref hash; render -p dereferences it and
|
||||||
|
// renders the stored node's payload.
|
||||||
|
const { stdout, exitCode } = await runCliWithStdin(
|
||||||
|
["render", "--pipe"],
|
||||||
|
putOut,
|
||||||
|
);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("Bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("8.3 list --type @schema emits a parseable envelope of hashes", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["list", "--type", "@schema"]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
// Downstream consumers (jq, etc.) read the `value` array of hashes.
|
||||||
|
const value = envValue(stdout) as string[];
|
||||||
|
expect(Array.isArray(value)).toBe(true);
|
||||||
|
for (const hash of value) {
|
||||||
|
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("8.4 list --type @schema | render -p expands the schema list", async () => {
|
||||||
|
const { stdout: listOut } = await runCli(["list", "--type", "@schema"]);
|
||||||
|
// list result items are cas_ref hashes; render -p dereferences each one
|
||||||
|
// and renders the schema contents.
|
||||||
|
const { stdout, exitCode } = await runCliWithStdin(
|
||||||
|
["render", "--pipe"],
|
||||||
|
listOut,
|
||||||
|
);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("8.5 render <hash> uses a registered template", async () => {
|
||||||
|
// Register a template for the schema, then render a fresh node by hash.
|
||||||
|
const tmplFile = join(tmpStore, "pipe-render.liquid");
|
||||||
|
writeFileSync(tmplFile, "Person: {{ payload.name }} ({{ payload.age }})");
|
||||||
|
const { exitCode: setExit } = await runCli([
|
||||||
|
"template",
|
||||||
|
"set",
|
||||||
|
typeHash,
|
||||||
|
tmplFile,
|
||||||
|
]);
|
||||||
|
expect(setExit).toBe(0);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "pipe-render-node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Carol", age: 25 }));
|
||||||
|
const { stdout: putOut } = await runCli(["put", typeHash, nodeFile]);
|
||||||
|
const freshHash = envValue(putOut) as string;
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCli(["render", freshHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toBe("Person: Carol (25)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Phase 9: Put/Hash Pipe Input ----
|
||||||
|
|
||||||
|
describe("Phase 9: Put/Hash Pipe Input", () => {
|
||||||
|
test("9.1 put -p reads JSON from stdin and stores node", async () => {
|
||||||
|
const payload = JSON.stringify({ name: "PipeAlice", age: 99 });
|
||||||
|
const { stdout, exitCode } = await runCliWithStdin(
|
||||||
|
["put", typeHash, "-p"],
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
const hash = envValue(stdout);
|
||||||
|
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
|
||||||
|
// Verify stored correctly
|
||||||
|
const { stdout: getOut } = await runCli(["get", hash as string]);
|
||||||
|
expect(getOut).toContain("PipeAlice");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("9.2 hash -p reads JSON from stdin and computes hash without storing", async () => {
|
||||||
|
const payload = JSON.stringify({ name: "PipeBob", age: 55 });
|
||||||
|
const { stdout, exitCode } = await runCliWithStdin(
|
||||||
|
["hash", typeHash, "-p"],
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
const hash = envValue(stdout);
|
||||||
|
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
|
||||||
|
// Should NOT be stored
|
||||||
|
const { exitCode: hasExit, stdout: hasOut } = await runCli([
|
||||||
|
"has",
|
||||||
|
hash as string,
|
||||||
|
]);
|
||||||
|
expect(hasExit).toBe(0);
|
||||||
|
expect(envValue(hasOut)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("9.3 put -p with file arg errors", async () => {
|
||||||
|
const { stderr, exitCode } = await runCliWithStdin(
|
||||||
|
["put", typeHash, "some-file.json", "-p"],
|
||||||
|
"{}",
|
||||||
|
);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Cannot use --pipe/-p with a file argument");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("9.4 put -p with empty stdin errors", async () => {
|
||||||
|
const { stderr, exitCode } = await runCliWithStdin(
|
||||||
|
["put", typeHash, "-p"],
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("No input on stdin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("9.5 put -p with invalid JSON errors", async () => {
|
||||||
|
const { stderr, exitCode } = await runCliWithStdin(
|
||||||
|
["put", typeHash, "-p"],
|
||||||
|
"not json",
|
||||||
|
);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Invalid JSON on stdin");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { envValue, stripVolatile } from "./helpers";
|
||||||
|
|
||||||
|
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||||
|
|
||||||
|
let tmpStore: string;
|
||||||
|
let varDbPath: string;
|
||||||
|
let typeHash: string;
|
||||||
|
let nodeHash: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||||
|
varDbPath = join(tmpStore, "variables.db");
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "test-schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
age: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
|
const store = await openFsStore(tmpStore);
|
||||||
|
typeHash = await putSchema(
|
||||||
|
store,
|
||||||
|
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
|
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||||
|
nodeHash = envValue(stdout) as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runCli(
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Phase 1: CAS Core", () => {
|
||||||
|
test("1.1 init + put with @object bootstraps store", async () => {
|
||||||
|
expect(typeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.5 put returns node hash", async () => {
|
||||||
|
expect(nodeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.6 get returns node JSON (snapshot)", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["get", nodeHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.7 has returns true for existing node", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["has", nodeHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.8 has returns false for non-existing hash", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["has", "AAAAAAAAAAAAA"]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.12 hash dry-run returns same hash as put", async () => {
|
||||||
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
|
const { stdout, exitCode } = await runCli(["hash", typeHash, nodeFile]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toBe(nodeHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.13 list --type returns nodes of that type", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["list", "--type", typeHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toContain(nodeHash);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { bootstrap } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import { envValue, putSchemaFile, runCli, runCliWithStdin } from "./helpers";
|
||||||
|
|
||||||
|
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||||
|
|
||||||
|
// --- Standalone render tests from cli.test.ts ---
|
||||||
|
|
||||||
|
describe("ucas render command", () => {
|
||||||
|
test("R1: render requires hash argument", async () => {
|
||||||
|
const { exitCode, stderr } = await runCli(["render"]);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Usage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("R2: render with missing hash shows error", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
const { exitCode, stderr } = await runCli(
|
||||||
|
["render", "ZZZZZZZZZZZZZ"],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
// Missing hash should exit with error
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Node not found");
|
||||||
|
expect(stderr).toContain("ZZZZZZZZZZZZZ");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("R3: render with invalid numeric flag fails", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
const { exitCode, stderr } = await runCli(
|
||||||
|
["render", "AAAAAAAAAAAAA", "--resolution", "invalid"],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("valid number");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- e2e Phase 5: Render ---
|
||||||
|
|
||||||
|
describe("Phase 5: Render", () => {
|
||||||
|
let tmpStore: string;
|
||||||
|
let varDbPath: string;
|
||||||
|
let typeHash: string;
|
||||||
|
let nodeHash: string;
|
||||||
|
|
||||||
|
async function runCliE2e(
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCliE2eWithStdin(
|
||||||
|
args: string[],
|
||||||
|
stdin: string,
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
proc.stdin.write(stdin);
|
||||||
|
proc.stdin.end();
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||||
|
varDbPath = join(tmpStore, "variables.db");
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "test-schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
|
const store = await openFsStore(tmpStore);
|
||||||
|
typeHash = await putSchema(
|
||||||
|
store,
|
||||||
|
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
|
const { stdout } = await runCliE2e(["put", typeHash, nodeFile]);
|
||||||
|
nodeHash = envValue(stdout) as string;
|
||||||
|
|
||||||
|
// Register template for render tests
|
||||||
|
const tmplFile = join(tmpStore, "render-template.liquid");
|
||||||
|
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
|
||||||
|
await runCliE2e(["template", "set", typeHash, tmplFile]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("5.1 render fills payload variables", async () => {
|
||||||
|
const { stdout, exitCode } = await runCliE2e(["render", nodeHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toBe("Hello Alice!");
|
||||||
|
expect(stdout).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("5.2 render --resolution with different value", async () => {
|
||||||
|
const { stdout, exitCode } = await runCliE2e([
|
||||||
|
"render",
|
||||||
|
nodeHash,
|
||||||
|
"--resolution",
|
||||||
|
"0.5",
|
||||||
|
]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("5.3 render non-existent hash fails with error", async () => {
|
||||||
|
const { stderr, exitCode } = await runCliE2e(["render", "ZZZZZZZZZZZZZ"]);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Node not found");
|
||||||
|
expect(stderr).toContain("ZZZZZZZZZZZZZ");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Suite 6: CLI Integration with Templates (from cli.test.ts) ---
|
||||||
|
|
||||||
|
describe("Suite 6: CLI Integration with Templates", () => {
|
||||||
|
test("6.1 CLI with Template (Default Parameters)", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
// Initialize store
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Create schema
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Create node
|
||||||
|
const nodeFile = join(tmpStore, "node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice" }));
|
||||||
|
const { stdout: nodeOut } = await runCli(
|
||||||
|
["put", schemaHash.trim(), nodeFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
const nodeHash = envValue(nodeOut) as string;
|
||||||
|
|
||||||
|
// Create template file (JSON-encoded string)
|
||||||
|
const templateFile = join(tmpStore, "template.json");
|
||||||
|
writeFileSync(templateFile, JSON.stringify("Hello {{ payload.name }}!"));
|
||||||
|
const { stdout: tmplOut } = await runCli(
|
||||||
|
["put", "@string", templateFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
const tmplHash = envValue(tmplOut) as string;
|
||||||
|
|
||||||
|
// Register template
|
||||||
|
await runCli(
|
||||||
|
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render with template
|
||||||
|
const { stdout: output, exitCode } = await runCli(
|
||||||
|
["render", nodeHash],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toBe("Hello Alice!");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("6.2 CLI with Template + Custom Decay", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Create schema with child ref
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
value: { type: "string" },
|
||||||
|
child: {
|
||||||
|
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Create child node
|
||||||
|
const childFile = join(tmpStore, "child.json");
|
||||||
|
writeFileSync(childFile, JSON.stringify({ value: "child", child: null }));
|
||||||
|
const { stdout: childOut } = await runCli(
|
||||||
|
["put", schemaHash.trim(), childFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
const childHash = envValue(childOut) as string;
|
||||||
|
|
||||||
|
// Create parent node
|
||||||
|
const parentFile = join(tmpStore, "parent.json");
|
||||||
|
writeFileSync(
|
||||||
|
parentFile,
|
||||||
|
JSON.stringify({ value: "parent", child: childHash }),
|
||||||
|
);
|
||||||
|
const { stdout: parentOut } = await runCli(
|
||||||
|
["put", schemaHash.trim(), parentFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
const parentHash = envValue(parentOut) as string;
|
||||||
|
|
||||||
|
// Create template showing resolution (JSON-encoded string)
|
||||||
|
const templateFile = join(tmpStore, "template.json");
|
||||||
|
writeFileSync(
|
||||||
|
templateFile,
|
||||||
|
JSON.stringify("{{ payload.value }}(res={{ resolution }})"),
|
||||||
|
);
|
||||||
|
const { stdout: tmplOut } = await runCli(
|
||||||
|
["put", "@string", templateFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
const tmplHash = envValue(tmplOut) as string;
|
||||||
|
|
||||||
|
// Register template
|
||||||
|
await runCli(
|
||||||
|
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render with custom decay
|
||||||
|
const { stdout: output, exitCode } = await runCli(
|
||||||
|
["render", parentHash, "--decay", "0.7"],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toContain("parent(res=1)");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("6.3 CLI with Template + All Parameters", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Bob" }));
|
||||||
|
const { stdout: nodeOut } = await runCli(
|
||||||
|
["put", schemaHash.trim(), nodeFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
const nodeHash = envValue(nodeOut) as string;
|
||||||
|
|
||||||
|
// Create template (JSON-encoded string)
|
||||||
|
const templateFile = join(tmpStore, "template.json");
|
||||||
|
writeFileSync(
|
||||||
|
templateFile,
|
||||||
|
JSON.stringify("Greetings {{ payload.name }}!"),
|
||||||
|
);
|
||||||
|
const { stdout: tmplOut } = await runCli(
|
||||||
|
["put", "@string", templateFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
const tmplHash = envValue(tmplOut) as string;
|
||||||
|
|
||||||
|
await runCli(
|
||||||
|
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdout: output, exitCode } = await runCli(
|
||||||
|
[
|
||||||
|
"render",
|
||||||
|
nodeHash,
|
||||||
|
"--resolution",
|
||||||
|
"0.8",
|
||||||
|
"--decay",
|
||||||
|
"0.6",
|
||||||
|
"--epsilon",
|
||||||
|
"0.005",
|
||||||
|
],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toBe("Greetings Bob!");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("6.4 CLI with Non-templated Node (YAML Fallback)", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" }));
|
||||||
|
const { stdout: nodeOut } = await runCli(
|
||||||
|
["put", schemaHash.trim(), nodeFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
const nodeHash = envValue(nodeOut) as string;
|
||||||
|
|
||||||
|
// No template registered - should fall back to YAML
|
||||||
|
const { stdout: output, exitCode } = await runCli(
|
||||||
|
["render", nodeHash],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toContain("name:");
|
||||||
|
expect(output).toContain("Charlie");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("6.5 CLI Error: Invalid Decay Value", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Test" }));
|
||||||
|
const { stdout: nodeOut } = await runCli(
|
||||||
|
["put", schemaHash.trim(), nodeFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
const nodeHash = envValue(nodeOut) as string;
|
||||||
|
|
||||||
|
const { exitCode, stderr } = await runCli(
|
||||||
|
["render", nodeHash, "--decay", "1.5"],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("decay");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("R8: render with non-existent hash exits with error", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
const { exitCode, stderr, stdout } = await runCli(
|
||||||
|
["render", "AAAAAAAAAAAAA"],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Node not found");
|
||||||
|
expect(stderr).toContain("AAAAAAAAAAAAA");
|
||||||
|
expect(stdout).toBe("");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("R9: render with valid hash exits successfully", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Get @string type hash via bootstrap
|
||||||
|
const store = createFsStore(tmpStore);
|
||||||
|
const types = await bootstrap(store);
|
||||||
|
const stringType = types["@string"];
|
||||||
|
|
||||||
|
// Create and store a simple string node
|
||||||
|
const nodeFile = join(tmpStore, "test.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify("hello world"));
|
||||||
|
const { stdout: nodeOut } = await runCli(
|
||||||
|
["put", stringType, nodeFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
const nodeHash = envValue(nodeOut) as string;
|
||||||
|
|
||||||
|
// Render the valid hash
|
||||||
|
const { exitCode, stdout, stderr } = await runCli(
|
||||||
|
["render", nodeHash],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("hello world");
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
expect(stdout).not.toContain("Error");
|
||||||
|
expect(stdout).not.toContain("Node not found");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("R10: render --pipe with valid envelope succeeds", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Get @string type hash via bootstrap
|
||||||
|
const store = createFsStore(tmpStore);
|
||||||
|
const types = await bootstrap(store);
|
||||||
|
const stringType = types["@string"];
|
||||||
|
|
||||||
|
// Create envelope and pipe to render
|
||||||
|
const envelope = JSON.stringify({ type: stringType, value: "test" });
|
||||||
|
const { exitCode, stdout, stderr } = await runCliWithStdin(
|
||||||
|
["render", "--pipe"],
|
||||||
|
tmpStore,
|
||||||
|
envelope,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("test");
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("R11: render --pipe with invalid type hash still renders", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Use invalid type hash in envelope
|
||||||
|
const envelope = JSON.stringify({
|
||||||
|
type: "ZZZZZZZZZZZZZ",
|
||||||
|
value: "test",
|
||||||
|
});
|
||||||
|
const { exitCode, stdout, stderr } = await runCliWithStdin(
|
||||||
|
["render", "--pipe"],
|
||||||
|
tmpStore,
|
||||||
|
envelope,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("test");
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store validation tests removed - with auto-bootstrap in Phase 1a,
|
||||||
|
// stores are automatically created and bootstrapped when opened.
|
||||||
|
// Issue #55 validation is no longer applicable.
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { envValue, putSchemaFile, runCli } from "./helpers";
|
||||||
|
|
||||||
|
// ---- Issue #50: Schema Validation in put Command ----
|
||||||
|
|
||||||
|
describe("Issue #50: Schema Validation in put", () => {
|
||||||
|
describe("Test Group 1: Valid Data (Regression Tests)", () => {
|
||||||
|
test("T1.1: Valid data matching schema should be accepted", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Create schema with required name property
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Create valid payload
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
|
||||||
|
|
||||||
|
const { stdout, stderr, exitCode } = await runCli(
|
||||||
|
["put", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
|
||||||
|
// Verify node was stored
|
||||||
|
const hash = envValue(stdout) as string;
|
||||||
|
const { exitCode: hasExitCode } = await runCli(["has", hash], tmpStore);
|
||||||
|
expect(hasExitCode).toBe(0);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T1.2: Valid data with optional properties should be accepted", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Schema with optional property
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
age: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Payload with only required properties
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
|
||||||
|
|
||||||
|
const { exitCode, stdout } = await runCli(
|
||||||
|
["put", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T1.3: Valid data with nested objects should be accepted", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Schema with nested structure
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
address: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
street: { type: "string" },
|
||||||
|
city: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Payload with nested structure
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(
|
||||||
|
payloadFile,
|
||||||
|
JSON.stringify({
|
||||||
|
name: "test",
|
||||||
|
address: { street: "123 Main", city: "NYC" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { exitCode, stdout } = await runCli(
|
||||||
|
["put", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T1.4: Valid data using @ alias for type-hash should be accepted", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify("hello world"));
|
||||||
|
|
||||||
|
const { exitCode, stdout } = await runCli(
|
||||||
|
["put", "@string", payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Test Group 2: Type Mismatches (New Validation)", () => {
|
||||||
|
test("T2.1: Wrong property type should be rejected", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Schema with name as string
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Payload with name as number
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||||
|
|
||||||
|
const { exitCode, stdout, stderr } = await runCli(
|
||||||
|
["put", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toBe("");
|
||||||
|
expect(stderr).toContain("Validation failed");
|
||||||
|
expect(stderr).toContain(schemaHash.trim());
|
||||||
|
expect(stderr).toContain(payloadFile);
|
||||||
|
|
||||||
|
// Verify no node was stored
|
||||||
|
const { stdout: hasOutput } = await runCli(
|
||||||
|
["has", "0000000000000"],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
expect(envValue(hasOutput)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T2.2: Missing required property should be rejected", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Schema with required name
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Empty payload
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({}));
|
||||||
|
|
||||||
|
const { exitCode, stderr } = await runCli(
|
||||||
|
["put", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Validation failed");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T2.3: Additional properties when disallowed should be rejected", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Schema with additionalProperties: false
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Payload with extra property
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(
|
||||||
|
payloadFile,
|
||||||
|
JSON.stringify({ name: "test", extra: "not allowed" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { exitCode, stderr } = await runCli(
|
||||||
|
["put", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Validation failed");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T2.4: Wrong root type should be rejected", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Schema expecting array
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Payload is an object
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({}));
|
||||||
|
|
||||||
|
const { exitCode, stderr } = await runCli(
|
||||||
|
["put", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Validation failed");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T2.5: Nested type mismatch should be rejected", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Schema with nested object
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
age: { type: "number" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Payload with wrong nested type
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(
|
||||||
|
payloadFile,
|
||||||
|
JSON.stringify({ user: { age: "not a number" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { exitCode, stderr } = await runCli(
|
||||||
|
["put", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Validation failed");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Test Group 3: Schema Errors (Edge Cases)", () => {
|
||||||
|
test("T3.1: Non-existent type-hash should fail gracefully", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
|
||||||
|
|
||||||
|
const { exitCode, stderr } = await runCli(
|
||||||
|
["put", "ZZZZZZZZZZZZZ", payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Schema not found");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T3.3: Invalid @ alias should fail before validation", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({}));
|
||||||
|
|
||||||
|
const { exitCode, stderr } = await runCli(
|
||||||
|
["put", "@nonexistent", payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Schema not found");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Test Group 4: Integration with Existing Features", () => {
|
||||||
|
test("T4.1: Hash command should not validate (dry-run consistency)", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Create schema
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Invalid payload
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||||
|
|
||||||
|
// Hash command should succeed even with invalid data
|
||||||
|
const { exitCode, stdout } = await runCli(
|
||||||
|
["hash", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T4.2: Validation respects cas_ref format in schemas", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Schema with cas_ref format
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
ref: { type: "string", format: "cas_ref" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
// Valid cas_ref
|
||||||
|
const validFile = join(tmpStore, "valid.json");
|
||||||
|
writeFileSync(validFile, JSON.stringify({ ref: "0000000000000" }));
|
||||||
|
|
||||||
|
const { exitCode: validExitCode } = await runCli(
|
||||||
|
["put", schemaHash.trim(), validFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
expect(validExitCode).toBe(0);
|
||||||
|
|
||||||
|
// Invalid cas_ref (wrong length)
|
||||||
|
const invalidFile = join(tmpStore, "invalid.json");
|
||||||
|
writeFileSync(invalidFile, JSON.stringify({ ref: "short" }));
|
||||||
|
|
||||||
|
const { exitCode: invalidExitCode, stderr } = await runCli(
|
||||||
|
["put", schemaHash.trim(), invalidFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
expect(invalidExitCode).not.toBe(0);
|
||||||
|
expect(stderr).toContain("Validation failed");
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T4.3: Schema self-validation still works", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
// Valid schema
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Test Group 5: Error Message Quality", () => {
|
||||||
|
test("T5.1: Error message should be helpful", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
required: ["name"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||||
|
|
||||||
|
const { stderr } = await runCli(
|
||||||
|
["put", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stderr).toContain("Validation failed");
|
||||||
|
expect(stderr).toContain(schemaHash.trim());
|
||||||
|
expect(stderr).toContain(payloadFile);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T5.2: Error should go to stderr, not stdout", async () => {
|
||||||
|
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||||
|
try {
|
||||||
|
await runCli(["init"], tmpStore);
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
|
||||||
|
|
||||||
|
const payloadFile = join(tmpStore, "payload.json");
|
||||||
|
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||||
|
|
||||||
|
const { stdout, stderr } = await runCli(
|
||||||
|
["put", schemaHash.trim(), payloadFile],
|
||||||
|
tmpStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stdout).toBe("");
|
||||||
|
expect(stderr.length).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// e2e Phase 2 tests
|
||||||
|
describe("Phase 2: Schema Validation", () => {
|
||||||
|
let tmpStore: string;
|
||||||
|
let varDbPath: string;
|
||||||
|
let typeHash: string;
|
||||||
|
let nodeHash: string;
|
||||||
|
|
||||||
|
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||||
|
varDbPath = join(tmpStore, "variables.db");
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "test-schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
|
const store = await openFsStore(tmpStore);
|
||||||
|
typeHash = await putSchema(
|
||||||
|
store,
|
||||||
|
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"bun",
|
||||||
|
entrypoint,
|
||||||
|
"--store",
|
||||||
|
tmpStore,
|
||||||
|
"--var-db",
|
||||||
|
varDbPath,
|
||||||
|
"put",
|
||||||
|
typeHash,
|
||||||
|
nodeFile,
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
nodeHash = envValue(stdout) as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => {
|
||||||
|
const badFile = join(tmpStore, "bad-node.json");
|
||||||
|
writeFileSync(badFile, JSON.stringify({ name: 123 }));
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"bun",
|
||||||
|
entrypoint,
|
||||||
|
"--store",
|
||||||
|
tmpStore,
|
||||||
|
"--var-db",
|
||||||
|
varDbPath,
|
||||||
|
"put",
|
||||||
|
typeHash,
|
||||||
|
badFile,
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stdout).toBe("");
|
||||||
|
expect(stderr).toContain("Validation failed");
|
||||||
|
expect(stderr).toContain(typeHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2.3 put against non-existent schema hash fails", async () => {
|
||||||
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"bun",
|
||||||
|
entrypoint,
|
||||||
|
"--store",
|
||||||
|
tmpStore,
|
||||||
|
"--var-db",
|
||||||
|
varDbPath,
|
||||||
|
"put",
|
||||||
|
"AAAAAAAAAAAAA",
|
||||||
|
nodeFile,
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
expect(exitCode).not.toBe(0);
|
||||||
|
expect(stderr).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
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, "../src/index.ts");
|
||||||
|
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
mkdirSync(storePath, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up test directory
|
||||||
|
try {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run CLI command and return stdout, stderr, and exit code
|
||||||
|
*/
|
||||||
|
async function 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 envelope = JSON.parse(stdout);
|
||||||
|
expect(envelope).toHaveProperty("type");
|
||||||
|
expect(envelope.value).toHaveProperty("contentHash");
|
||||||
|
expect(envelope.value.schemaHash).toBe(stringHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("set template with --inline flag", async () => {
|
||||||
|
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 envelope = JSON.parse(stdout);
|
||||||
|
expect(envelope).toHaveProperty("type");
|
||||||
|
expect(envelope.value).toHaveProperty("contentHash");
|
||||||
|
expect(envelope.value.schemaHash).toBe(stringHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update existing template (idempotent)", async () => {
|
||||||
|
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 envelope = JSON.parse(stdout);
|
||||||
|
expect(envelope.value).toHaveProperty("contentHash");
|
||||||
|
|
||||||
|
// Verify we can get the new version
|
||||||
|
const { stdout: getOut } = await runCli("template", "get", stringHash);
|
||||||
|
expect(JSON.parse(getOut).value).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(JSON.parse(getOut).value).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 envelope = JSON.parse(stdout);
|
||||||
|
expect(envelope.value).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(JSON.parse(getOut).value).toBe(specialContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("template get", () => {
|
||||||
|
test("retrieve template as envelope value", 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("");
|
||||||
|
const envelope = JSON.parse(stdout);
|
||||||
|
expect(envelope).toHaveProperty("type");
|
||||||
|
expect(envelope.value).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);
|
||||||
|
|
||||||
|
// The envelope's value preserves exact whitespace (JSON-escaped),
|
||||||
|
// so trimming the surrounding JSON output is harmless.
|
||||||
|
const content = "spaces\n\ttabs\t\nmixed";
|
||||||
|
await runCli("template", "set", stringHash, "--inline", content);
|
||||||
|
|
||||||
|
const { stdout } = await runCli("template", "get", stringHash);
|
||||||
|
|
||||||
|
expect(JSON.parse(stdout).value).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("support multi-line templates", async () => {
|
||||||
|
const store = createFsStore(storePath);
|
||||||
|
const stringHash = await getStringHash(store);
|
||||||
|
|
||||||
|
const multiline = "Line 1\nLine 2\nLine 3";
|
||||||
|
await runCli("template", "set", stringHash, "--inline", multiline);
|
||||||
|
|
||||||
|
const { stdout } = await runCli("template", "get", stringHash);
|
||||||
|
|
||||||
|
expect(JSON.parse(stdout).value).toBe(multiline);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 envelope = JSON.parse(stdout);
|
||||||
|
expect(envelope).toHaveProperty("type");
|
||||||
|
expect(Array.isArray(envelope.value)).toBe(true);
|
||||||
|
expect(envelope.value.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Check structure
|
||||||
|
const item = envelope.value[0];
|
||||||
|
expect(item).toHaveProperty("schemaHash");
|
||||||
|
expect(item).toHaveProperty("contentHash");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("entry contentHash matches set result", async () => {
|
||||||
|
const store = createFsStore(storePath);
|
||||||
|
const stringHash = await getStringHash(store);
|
||||||
|
|
||||||
|
const { stdout: setOut } = await runCli(
|
||||||
|
"template",
|
||||||
|
"set",
|
||||||
|
stringHash,
|
||||||
|
"--inline",
|
||||||
|
"Some template content",
|
||||||
|
);
|
||||||
|
const { contentHash } = JSON.parse(setOut).value;
|
||||||
|
|
||||||
|
const { stdout } = await runCli("template", "list");
|
||||||
|
|
||||||
|
const value = JSON.parse(stdout).value as Array<{
|
||||||
|
schemaHash: string;
|
||||||
|
contentHash: string;
|
||||||
|
}>;
|
||||||
|
const item = value.find((i) => i.schemaHash === stringHash);
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
if (item) {
|
||||||
|
expect(item.contentHash).toBe(contentHash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty list when no templates", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli("template", "list");
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
const envelope = JSON.parse(stdout);
|
||||||
|
expect(Array.isArray(envelope.value)).toBe(true);
|
||||||
|
expect(envelope.value.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exclude non-template variables", async () => {
|
||||||
|
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 envelope = JSON.parse(stdout);
|
||||||
|
// Should only contain template variables
|
||||||
|
for (const item of envelope.value) {
|
||||||
|
expect(item.schemaHash).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("output JSON envelope with array value", 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 envelope = JSON.parse(stdout);
|
||||||
|
expect(Array.isArray(envelope.value)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 envelope = JSON.parse(stdout);
|
||||||
|
expect(envelope).toHaveProperty("type");
|
||||||
|
expect(envelope.value).toHaveProperty("deleted");
|
||||||
|
expect(envelope.value.deleted).toBe(true);
|
||||||
|
|
||||||
|
// Verify template is gone
|
||||||
|
const { exitCode: getExitCode } = await runCli(
|
||||||
|
"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 value = JSON.parse(stdout).value as Array<{
|
||||||
|
schemaHash: string;
|
||||||
|
contentHash: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Should not find deleted template
|
||||||
|
const deleted = value.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).value;
|
||||||
|
|
||||||
|
// 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(JSON.parse(getOut).value).toBe(content);
|
||||||
|
|
||||||
|
// List
|
||||||
|
const { stdout: listOut, exitCode: listExit } = await runCli(
|
||||||
|
"template",
|
||||||
|
"list",
|
||||||
|
);
|
||||||
|
expect(listExit).toBe(0);
|
||||||
|
const listData = JSON.parse(listOut).value;
|
||||||
|
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 value = JSON.parse(stdout).value;
|
||||||
|
expect(value.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:");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { envValue, stripVolatile } from "./helpers";
|
||||||
|
|
||||||
|
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||||
|
|
||||||
|
let tmpStore: string;
|
||||||
|
let varDbPath: string;
|
||||||
|
let typeHash: string;
|
||||||
|
let nodeHash: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
|
||||||
|
varDbPath = join(tmpStore, "variables.db");
|
||||||
|
|
||||||
|
const schemaFile = join(tmpStore, "test-schema.json");
|
||||||
|
writeFileSync(
|
||||||
|
schemaFile,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
|
const store = await openFsStore(tmpStore);
|
||||||
|
typeHash = await putSchema(
|
||||||
|
store,
|
||||||
|
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
|
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||||
|
nodeHash = envValue(stdout) as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(tmpStore, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runCli(
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||||
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||||
|
return { stdout, stderr, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Phase 1: CAS Core", () => {
|
||||||
|
test("1.9 verify returns ok for valid node", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.10 refs lists direct references (snapshot)", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["refs", nodeHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.11 walk shows traversal tree (snapshot)", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["walk", nodeHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Phase 2: Schema Validation", () => {
|
||||||
|
test("2.2 verify on valid node returns ok (hash + schema)", async () => {
|
||||||
|
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(envValue(stdout)).toBe("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,32 @@
|
|||||||
# @uncaged/json-cas-fs
|
# @uncaged/json-cas-fs
|
||||||
|
|
||||||
|
## 0.6.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
|
||||||
|
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
|
||||||
|
- `openStore()` is now async and auto-bootstraps
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- 18 `@output/*` schemas registered at bootstrap
|
||||||
|
- `list --type <hash-or-alias>` command (replaces `schema list`)
|
||||||
|
- `verify` now checks both hash integrity and schema validation
|
||||||
|
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
|
||||||
|
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
|
||||||
|
- Default LiquidJS templates for all output schemas
|
||||||
|
- Pipe composition: any command output can be piped to `render -p`
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies []:
|
||||||
|
- @uncaged/json-cas@0.6.0
|
||||||
|
|
||||||
## 0.5.3
|
## 0.5.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/json-cas-fs",
|
"name": "@uncaged/json-cas-fs",
|
||||||
"version": "0.5.3",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
|
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.5.3",
|
"@uncaged/json-cas": "^0.6.0",
|
||||||
"cborg": "^4.2.3"
|
"cborg": "^4.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { createFsStore } from "./store.js";
|
export { createFsStore, openStore } from "./store.js";
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { CasNode } from "@uncaged/json-cas";
|
import type { CasNode } from "@uncaged/json-cas";
|
||||||
import {
|
import {
|
||||||
|
BOOTSTRAP_STORE,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
computeHash,
|
computeHash,
|
||||||
computeSelfHash,
|
computeSelfHash,
|
||||||
verify,
|
verify,
|
||||||
} from "@uncaged/json-cas";
|
} from "@uncaged/json-cas";
|
||||||
|
|
||||||
import { createFsStore } from "./store.js";
|
import { createFsStore, openStore } from "./store.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "json-cas-fs-test-"));
|
return mkdtempSync(join(tmpdir(), "json-cas-fs-test-"));
|
||||||
@@ -43,7 +51,8 @@ describe("createFsStore – init and bootstrap", () => {
|
|||||||
|
|
||||||
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
|
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
|
||||||
const store = createFsStore(dir);
|
const store = createFsStore(dir);
|
||||||
const hash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const hash = builtinSchemas["@schema"] ?? "";
|
||||||
|
|
||||||
expect(hash).toHaveLength(13);
|
expect(hash).toHaveLength(13);
|
||||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||||
@@ -57,8 +66,8 @@ describe("createFsStore – init and bootstrap", () => {
|
|||||||
const h1 = await bootstrap(store);
|
const h1 = await bootstrap(store);
|
||||||
const h2 = await bootstrap(store);
|
const h2 = await bootstrap(store);
|
||||||
|
|
||||||
expect(h1).toBe(h2);
|
expect(h1).toEqual(h2);
|
||||||
expect(store.listByType(h1)).toHaveLength(1);
|
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(26);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,7 +113,8 @@ describe("createFsStore – persistence round-trip", () => {
|
|||||||
|
|
||||||
test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => {
|
test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => {
|
||||||
const store1 = createFsStore(dir);
|
const store1 = createFsStore(dir);
|
||||||
const hash = await bootstrap(store1);
|
const builtinSchemas = await bootstrap(store1);
|
||||||
|
const hash = builtinSchemas["@schema"] ?? "";
|
||||||
|
|
||||||
const store2 = createFsStore(dir);
|
const store2 = createFsStore(dir);
|
||||||
const node = store2.get(hash) as CasNode;
|
const node = store2.get(hash) as CasNode;
|
||||||
@@ -251,10 +261,11 @@ describe("createFsStore – listByType", () => {
|
|||||||
|
|
||||||
test("bootstrap node is listed under its self type after reload", async () => {
|
test("bootstrap node is listed under its self type after reload", async () => {
|
||||||
const store1 = createFsStore(dir);
|
const store1 = createFsStore(dir);
|
||||||
const hash = await bootstrap(store1);
|
const builtinSchemas = await bootstrap(store1);
|
||||||
|
const hash = builtinSchemas["@schema"] ?? "";
|
||||||
|
|
||||||
const store2 = createFsStore(dir);
|
const store2 = createFsStore(dir);
|
||||||
expect(store2.listByType(hash)).toEqual([hash]);
|
expect(store2.listByType(hash)).toContain(hash);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,7 +295,8 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
|
|||||||
|
|
||||||
test("verify passes on a disk-loaded bootstrap node", async () => {
|
test("verify passes on a disk-loaded bootstrap node", async () => {
|
||||||
const store1 = createFsStore(dir);
|
const store1 = createFsStore(dir);
|
||||||
const hash = await bootstrap(store1);
|
const builtinSchemas = await bootstrap(store1);
|
||||||
|
const hash = builtinSchemas["@schema"] ?? "";
|
||||||
|
|
||||||
const store2 = createFsStore(dir);
|
const store2 = createFsStore(dir);
|
||||||
const node = store2.get(hash) as CasNode;
|
const node = store2.get(hash) as CasNode;
|
||||||
@@ -308,3 +320,241 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// openStore – async with auto-bootstrap
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe("openStore – async with auto-bootstrap", () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = makeTmpDir();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openStore returns Promise<Store>", async () => {
|
||||||
|
const store = await openStore(dir);
|
||||||
|
expect(store).toBeDefined();
|
||||||
|
expect(typeof store.put).toBe("function");
|
||||||
|
expect(typeof store.get).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openStore auto-creates directory when it doesn't exist", async () => {
|
||||||
|
const nested = join(dir, "sub", "nested", "store");
|
||||||
|
expect(existsSync(nested)).toBe(false);
|
||||||
|
|
||||||
|
const store = await openStore(nested);
|
||||||
|
expect(existsSync(nested)).toBe(true);
|
||||||
|
|
||||||
|
// Verify store works
|
||||||
|
const typeHash = await computeSelfHash({ name: "t" });
|
||||||
|
const hash = await store.put(typeHash, { x: 1 });
|
||||||
|
expect(store.has(hash)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openStore works when directory already exists", async () => {
|
||||||
|
// Pre-create the directory
|
||||||
|
const store1 = await openStore(dir);
|
||||||
|
const typeHash = await computeSelfHash({ name: "t" });
|
||||||
|
await store1.put(typeHash, { x: 1 });
|
||||||
|
|
||||||
|
// Open again
|
||||||
|
const store2 = await openStore(dir);
|
||||||
|
expect(store2.listByType(typeHash)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openStore throws error when path exists but is not a directory", async () => {
|
||||||
|
const filePath = join(dir, "not-a-dir");
|
||||||
|
writeFileSync(filePath, "test");
|
||||||
|
|
||||||
|
await expect(openStore(filePath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openStore auto-bootstraps on first open (empty directory)", async () => {
|
||||||
|
const store = await openStore(dir);
|
||||||
|
|
||||||
|
// Check that bootstrap schemas exist
|
||||||
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"];
|
||||||
|
|
||||||
|
expect(metaHash).toBeDefined();
|
||||||
|
expect(store.has(metaHash as string)).toBe(true);
|
||||||
|
|
||||||
|
// Verify all core schemas exist
|
||||||
|
expect(store.has(builtinSchemas["@string"] as string)).toBe(true);
|
||||||
|
expect(store.has(builtinSchemas["@number"] as string)).toBe(true);
|
||||||
|
expect(store.has(builtinSchemas["@object"] as string)).toBe(true);
|
||||||
|
expect(store.has(builtinSchemas["@array"] as string)).toBe(true);
|
||||||
|
expect(store.has(builtinSchemas["@bool"] as string)).toBe(true);
|
||||||
|
expect(store.has(builtinSchemas["@schema"] as string)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openStore bootstrap is idempotent on subsequent opens", async () => {
|
||||||
|
const store1 = await openStore(dir);
|
||||||
|
const schemas1 = await bootstrap(store1);
|
||||||
|
const count1 = store1.listAll().length;
|
||||||
|
|
||||||
|
const store2 = await openStore(dir);
|
||||||
|
const schemas2 = await bootstrap(store2);
|
||||||
|
const count2 = store2.listAll().length;
|
||||||
|
|
||||||
|
// Same schemas, same count
|
||||||
|
expect(schemas1).toEqual(schemas2);
|
||||||
|
expect(count1).toBe(count2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openStore works on already-bootstrapped store", async () => {
|
||||||
|
// Bootstrap manually first
|
||||||
|
const store1 = createFsStore(dir);
|
||||||
|
const schemas1 = await bootstrap(store1);
|
||||||
|
|
||||||
|
// Open with openStore
|
||||||
|
const store2 = await openStore(dir);
|
||||||
|
const schemas2 = await bootstrap(store2);
|
||||||
|
|
||||||
|
expect(schemas1).toEqual(schemas2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openStore auto-bootstraps old store without bootstrap", async () => {
|
||||||
|
// Create a store with some data but no bootstrap
|
||||||
|
const store1 = createFsStore(dir);
|
||||||
|
const typeHash = await computeSelfHash({ name: "custom" });
|
||||||
|
await store1.put(typeHash, { data: "old" });
|
||||||
|
|
||||||
|
// Open with openStore - should auto-bootstrap
|
||||||
|
const store2 = await openStore(dir);
|
||||||
|
const schemas = await bootstrap(store2);
|
||||||
|
|
||||||
|
expect(store2.has(schemas["@schema"] as string)).toBe(true);
|
||||||
|
// Old data still exists
|
||||||
|
expect(store2.listByType(typeHash)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// listMeta and listSchemas (FS persistence)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe("createFsStore – listMeta and listSchemas", () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "json-cas-fs-meta-"));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C1. _index/_meta exists and contains hash after self-referencing put", async () => {
|
||||||
|
const store = createFsStore(dir);
|
||||||
|
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||||
|
|
||||||
|
const metaPath = join(dir, "_index", "_meta");
|
||||||
|
expect(existsSync(metaPath)).toBe(true);
|
||||||
|
const content = readFileSync(metaPath, "utf8");
|
||||||
|
expect(content).toBe(`${hash}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C2. multiple meta puts append, no duplicates", async () => {
|
||||||
|
const store = createFsStore(dir);
|
||||||
|
const h1 = await store[BOOTSTRAP_STORE]({ type: "object", v: 1 });
|
||||||
|
const h2 = await store[BOOTSTRAP_STORE]({ type: "object", v: 2 });
|
||||||
|
// re-put first (idempotent)
|
||||||
|
await store[BOOTSTRAP_STORE]({ type: "object", v: 1 });
|
||||||
|
|
||||||
|
const content = readFileSync(join(dir, "_index", "_meta"), "utf8");
|
||||||
|
const lines = content.split("\n").filter((l) => l.length > 0);
|
||||||
|
expect(lines).toHaveLength(2);
|
||||||
|
expect(lines).toContain(h1);
|
||||||
|
expect(lines).toContain(h2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C3. reload from disk preserves listMeta", async () => {
|
||||||
|
const store1 = createFsStore(dir);
|
||||||
|
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "a" });
|
||||||
|
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "b" });
|
||||||
|
|
||||||
|
const store2 = createFsStore(dir);
|
||||||
|
const meta = store2.listMeta();
|
||||||
|
expect(meta).toContain(h1);
|
||||||
|
expect(meta).toContain(h2);
|
||||||
|
expect(meta).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C4. migrates from existing nodes when _meta is missing", async () => {
|
||||||
|
const store1 = createFsStore(dir);
|
||||||
|
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "mig" });
|
||||||
|
const t = await computeSelfHash({ name: "regular" });
|
||||||
|
await store1.put(h1, { type: "string" });
|
||||||
|
|
||||||
|
// Remove _index/_meta but keep _index/* and .bin files
|
||||||
|
const metaPath = join(dir, "_index", "_meta");
|
||||||
|
rmSync(metaPath, { force: true });
|
||||||
|
expect(existsSync(metaPath)).toBe(false);
|
||||||
|
|
||||||
|
const store2 = createFsStore(dir);
|
||||||
|
expect(store2.listMeta()).toContain(h1);
|
||||||
|
expect(existsSync(metaPath)).toBe(true);
|
||||||
|
const content = readFileSync(metaPath, "utf8");
|
||||||
|
expect(content).toContain(h1);
|
||||||
|
|
||||||
|
// unrelated type hash not in meta
|
||||||
|
expect(store2.listMeta()).not.toContain(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C5. existing _meta is not overwritten", async () => {
|
||||||
|
const store1 = createFsStore(dir);
|
||||||
|
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "keep" });
|
||||||
|
|
||||||
|
const metaPath = join(dir, "_index", "_meta");
|
||||||
|
const before = readFileSync(metaPath, "utf8");
|
||||||
|
|
||||||
|
// Reopen and confirm content unchanged
|
||||||
|
const _store2 = createFsStore(dir);
|
||||||
|
const after = readFileSync(metaPath, "utf8");
|
||||||
|
expect(after).toBe(before);
|
||||||
|
expect(after).toContain(h1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C6. listSchemas returns union of typeIndex[m] for m in metaSet", async () => {
|
||||||
|
const store = createFsStore(dir);
|
||||||
|
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||||
|
const s1 = await store.put(m, { type: "string" });
|
||||||
|
const s2 = await store.put(m, { type: "number" });
|
||||||
|
const s3 = await store.put(m, { type: "array" });
|
||||||
|
|
||||||
|
const schemas = store.listSchemas();
|
||||||
|
expect(schemas).toHaveLength(4);
|
||||||
|
expect(schemas).toContain(m);
|
||||||
|
expect(schemas).toContain(s1);
|
||||||
|
expect(schemas).toContain(s2);
|
||||||
|
expect(schemas).toContain(s3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C7. delete persists removal from _meta", async () => {
|
||||||
|
const store1 = createFsStore(dir);
|
||||||
|
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: 1 });
|
||||||
|
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: 2 });
|
||||||
|
|
||||||
|
store1.delete(h1);
|
||||||
|
|
||||||
|
const metaPath = join(dir, "_index", "_meta");
|
||||||
|
const content = readFileSync(metaPath, "utf8");
|
||||||
|
expect(content).not.toContain(h1);
|
||||||
|
expect(content).toContain(h2);
|
||||||
|
|
||||||
|
const store2 = createFsStore(dir);
|
||||||
|
expect(store2.listMeta()).not.toContain(h1);
|
||||||
|
expect(store2.listMeta()).toContain(h2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C8. fresh store with no self-ref puts has empty listMeta", () => {
|
||||||
|
const store = createFsStore(dir);
|
||||||
|
expect(store.listMeta()).toEqual([]);
|
||||||
|
// _meta may be absent; that's fine
|
||||||
|
const metaPath = join(dir, "_index", "_meta");
|
||||||
|
if (existsSync(metaPath)) {
|
||||||
|
const content = readFileSync(metaPath, "utf8");
|
||||||
|
expect(content).toBe("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
readdirSync,
|
readdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
renameSync,
|
renameSync,
|
||||||
|
statSync,
|
||||||
unlinkSync,
|
unlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
@@ -13,6 +14,7 @@ import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BOOTSTRAP_STORE,
|
BOOTSTRAP_STORE,
|
||||||
|
bootstrap,
|
||||||
cborEncode,
|
cborEncode,
|
||||||
computeHash,
|
computeHash,
|
||||||
computeSelfHash,
|
computeSelfHash,
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
import { decode } from "cborg";
|
import { decode } from "cborg";
|
||||||
|
|
||||||
const INDEX_DIR = "_index";
|
const INDEX_DIR = "_index";
|
||||||
|
const META_FILE = "_meta";
|
||||||
|
|
||||||
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
|
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
@@ -98,6 +101,61 @@ function loadOrMigrateTypeIndex(
|
|||||||
return loadTypeIndex(indexDir);
|
return loadTypeIndex(indexDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadOrMigrateMetaSet(
|
||||||
|
dir: string,
|
||||||
|
data: Map<Hash, CasNode>,
|
||||||
|
): Set<Hash> {
|
||||||
|
const indexDir = join(dir, INDEX_DIR);
|
||||||
|
const metaPath = join(indexDir, META_FILE);
|
||||||
|
if (existsSync(metaPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(metaPath, "utf8");
|
||||||
|
return new Set(parseIndexFile(content));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Migration: scan loaded nodes for self-referencing nodes (type === hash)
|
||||||
|
const metaSet = new Set<Hash>();
|
||||||
|
for (const [hash, node] of data) {
|
||||||
|
if (node.type === hash) {
|
||||||
|
metaSet.add(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (metaSet.size > 0) {
|
||||||
|
mkdirSync(indexDir, { recursive: true });
|
||||||
|
const body = `${[...metaSet].join("\n")}\n`;
|
||||||
|
writeFileSync(metaPath, body, "utf8");
|
||||||
|
}
|
||||||
|
return metaSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToMetaSet(
|
||||||
|
indexDir: string,
|
||||||
|
metaSet: Set<Hash>,
|
||||||
|
hash: Hash,
|
||||||
|
): void {
|
||||||
|
if (metaSet.has(hash)) return;
|
||||||
|
metaSet.add(hash);
|
||||||
|
mkdirSync(indexDir, { recursive: true });
|
||||||
|
appendFileSync(join(indexDir, META_FILE), `${hash}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteMetaSet(indexDir: string, metaSet: Set<Hash>): void {
|
||||||
|
const metaPath = join(indexDir, META_FILE);
|
||||||
|
if (metaSet.size === 0) {
|
||||||
|
try {
|
||||||
|
unlinkSync(metaPath);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mkdirSync(indexDir, { recursive: true });
|
||||||
|
const body = `${[...metaSet].join("\n")}\n`;
|
||||||
|
writeFileSync(metaPath, body, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
function appendToTypeIndex(
|
function appendToTypeIndex(
|
||||||
indexDir: string,
|
indexDir: string,
|
||||||
typeIndex: Map<Hash, Hash[]>,
|
typeIndex: Map<Hash, Hash[]>,
|
||||||
@@ -116,6 +174,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
|||||||
loadDir(dir, data);
|
loadDir(dir, data);
|
||||||
const indexDir = join(dir, INDEX_DIR);
|
const indexDir = join(dir, INDEX_DIR);
|
||||||
const typeIndex = loadOrMigrateTypeIndex(dir, data);
|
const typeIndex = loadOrMigrateTypeIndex(dir, data);
|
||||||
|
const metaSet = loadOrMigrateMetaSet(dir, data);
|
||||||
|
|
||||||
async function putSelfReferencing(payload: unknown): Promise<Hash> {
|
async function putSelfReferencing(payload: unknown): Promise<Hash> {
|
||||||
const hash = await computeSelfHash(payload);
|
const hash = await computeSelfHash(payload);
|
||||||
@@ -134,6 +193,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
|||||||
|
|
||||||
appendToTypeIndex(indexDir, typeIndex, hash, hash);
|
appendToTypeIndex(indexDir, typeIndex, hash, hash);
|
||||||
}
|
}
|
||||||
|
appendToMetaSet(indexDir, metaSet, hash);
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +240,22 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
|||||||
return Array.from(data.keys());
|
return Array.from(data.keys());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listMeta(): Hash[] {
|
||||||
|
return Array.from(metaSet);
|
||||||
|
},
|
||||||
|
|
||||||
|
listSchemas(): Hash[] {
|
||||||
|
const result = new Set<Hash>();
|
||||||
|
for (const meta of metaSet) {
|
||||||
|
result.add(meta);
|
||||||
|
const list = typeIndex.get(meta);
|
||||||
|
if (list) {
|
||||||
|
for (const h of list) result.add(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(result);
|
||||||
|
},
|
||||||
|
|
||||||
delete(hash: Hash): void {
|
delete(hash: Hash): void {
|
||||||
const node = data.get(hash);
|
const node = data.get(hash);
|
||||||
if (node) {
|
if (node) {
|
||||||
@@ -211,6 +287,11 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
|||||||
writeFileSync(join(indexDir, node.type), body, "utf8");
|
writeFileSync(join(indexDir, node.type), body, "utf8");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Remove from meta set if applicable
|
||||||
|
if (metaSet.has(hash)) {
|
||||||
|
metaSet.delete(hash);
|
||||||
|
rewriteMetaSet(indexDir, metaSet);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -219,3 +300,57 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
|||||||
|
|
||||||
return store;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
# @uncaged/json-cas
|
# @uncaged/json-cas
|
||||||
|
|
||||||
|
## 0.6.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
|
||||||
|
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
|
||||||
|
- `openStore()` is now async and auto-bootstraps
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- 18 `@output/*` schemas registered at bootstrap
|
||||||
|
- `list --type <hash-or-alias>` command (replaces `schema list`)
|
||||||
|
- `verify` now checks both hash integrity and schema validation
|
||||||
|
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
|
||||||
|
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
|
||||||
|
- Default LiquidJS templates for all output schemas
|
||||||
|
- Pipe composition: any command output can be piped to `render -p`
|
||||||
|
|
||||||
## 0.5.3
|
## 0.5.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/json-cas",
|
"name": "@uncaged/json-cas",
|
||||||
"version": "0.5.3",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@@ -16,11 +16,12 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
|
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"cborg": "^4.2.3",
|
"cborg": "^4.2.3",
|
||||||
|
"liquidjs": "^10.27.0",
|
||||||
"xxhash-wasm": "^1.1.0"
|
"xxhash-wasm": "^1.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { bootstrap } from "./bootstrap.js";
|
||||||
|
import type { JSONSchema } from "./schema.js";
|
||||||
|
import { getSchema } from "./schema.js";
|
||||||
|
import { createMemoryStore } from "./store.js";
|
||||||
|
|
||||||
|
const OUTPUT_ALIASES = [
|
||||||
|
"@output/put",
|
||||||
|
"@output/get",
|
||||||
|
"@output/has",
|
||||||
|
"@output/hash",
|
||||||
|
"@output/verify",
|
||||||
|
"@output/refs",
|
||||||
|
"@output/walk",
|
||||||
|
"@output/list",
|
||||||
|
"@output/list-meta",
|
||||||
|
"@output/list-schema",
|
||||||
|
"@output/var-set",
|
||||||
|
"@output/var-get",
|
||||||
|
"@output/var-delete",
|
||||||
|
"@output/var-tag",
|
||||||
|
"@output/var-list",
|
||||||
|
"@output/template-set",
|
||||||
|
"@output/template-get",
|
||||||
|
"@output/template-list",
|
||||||
|
"@output/template-delete",
|
||||||
|
"@output/gc",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Built-in Schema Registration Tests
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("bootstrap - Built-in Schemas", () => {
|
||||||
|
test("should return map of 26 built-in schema aliases to hashes", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
|
||||||
|
// Should return object with 6 primitive + 20 output aliases = 26
|
||||||
|
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(26);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bootstrap - meta and schemas indexes (D1)", () => {
|
||||||
|
test("listMeta contains the bootstrap meta-schema hash", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const metaHash = aliases["@schema"];
|
||||||
|
expect(store.listMeta()).toContain(metaHash as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listSchemas contains meta-schema and all built-in schemas", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const schemas = store.listSchemas();
|
||||||
|
|
||||||
|
for (const [, hash] of Object.entries(aliases)) {
|
||||||
|
expect(schemas).toContain(hash);
|
||||||
|
}
|
||||||
|
expect(schemas.length).toBeGreaterThanOrEqual(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,17 +60,260 @@ const BOOTSTRAP_PAYLOAD = {
|
|||||||
enum: { type: "array" },
|
enum: { type: "array" },
|
||||||
const: {},
|
const: {},
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
|
// P1 leaf constraints
|
||||||
|
minimum: { type: "number" },
|
||||||
|
maximum: { type: "number" },
|
||||||
|
exclusiveMinimum: { type: "number" },
|
||||||
|
exclusiveMaximum: { type: "number" },
|
||||||
|
minLength: { type: "number" },
|
||||||
|
maxLength: { type: "number" },
|
||||||
|
pattern: { type: "string" },
|
||||||
|
minItems: { type: "number" },
|
||||||
|
maxItems: { type: "number" },
|
||||||
|
uniqueItems: { type: "boolean" },
|
||||||
|
// P2 combinators + conditionals
|
||||||
|
allOf: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "object", additionalProperties: false },
|
||||||
|
},
|
||||||
|
if: { type: "object", additionalProperties: false },
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable
|
||||||
|
then: { type: "object", additionalProperties: false },
|
||||||
|
else: { type: "object", additionalProperties: false },
|
||||||
|
patternProperties: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "object", additionalProperties: false },
|
||||||
|
},
|
||||||
|
prefixItems: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "object", additionalProperties: false },
|
||||||
|
},
|
||||||
|
// P2 leaf constraints
|
||||||
|
multipleOf: { type: "number" },
|
||||||
|
minProperties: { type: "number" },
|
||||||
|
maxProperties: { type: "number" },
|
||||||
|
default: {},
|
||||||
|
// P3 combinators
|
||||||
|
not: { type: "object", additionalProperties: false },
|
||||||
|
contains: { type: "object", additionalProperties: false },
|
||||||
|
propertyNames: { type: "object", additionalProperties: false },
|
||||||
|
// P3 metadata
|
||||||
|
examples: { type: "array" },
|
||||||
|
readOnly: { type: "boolean" },
|
||||||
|
writeOnly: { type: "boolean" },
|
||||||
|
deprecated: { type: "boolean" },
|
||||||
|
$comment: { type: "string" },
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const VARIABLE_PROPERTIES = {
|
||||||
|
name: { type: "string" },
|
||||||
|
schema: { type: "string", format: "cas_ref" },
|
||||||
|
value: { type: "string", format: "cas_ref" },
|
||||||
|
created: { type: "number" },
|
||||||
|
updated: { type: "number" },
|
||||||
|
tags: { type: "object" },
|
||||||
|
labels: { type: "array", items: { type: "string" } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||||
|
readonly [alias: string, schema: Record<string, unknown>]
|
||||||
|
> = [
|
||||||
|
[
|
||||||
|
"@output/put",
|
||||||
|
{ type: "string", format: "cas_ref", title: "ucas put result" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/get",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: { type: "string", format: "cas_ref" },
|
||||||
|
payload: {},
|
||||||
|
timestamp: { type: "number" },
|
||||||
|
},
|
||||||
|
title: "ucas get result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["@output/has", { type: "boolean", title: "ucas has result" }],
|
||||||
|
[
|
||||||
|
"@output/hash",
|
||||||
|
{ type: "string", format: "cas_ref", title: "ucas hash result" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/verify",
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
enum: ["ok", "corrupted", "invalid"],
|
||||||
|
title: "ucas verify result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/refs",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "cas_ref" },
|
||||||
|
title: "ucas refs result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/walk",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
title: "ucas walk result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/list",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "cas_ref" },
|
||||||
|
title: "ucas list result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/list-meta",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "cas_ref" },
|
||||||
|
title: "ucas list-meta result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/list-schema",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "cas_ref" },
|
||||||
|
title: "ucas list-schema result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-set",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { ...VARIABLE_PROPERTIES },
|
||||||
|
title: "ucas var set result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-get",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { ...VARIABLE_PROPERTIES },
|
||||||
|
title: "ucas var get result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-delete",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { ...VARIABLE_PROPERTIES },
|
||||||
|
title: "ucas var delete result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-tag",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { ...VARIABLE_PROPERTIES },
|
||||||
|
title: "ucas var tag result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-list",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: { type: "object", properties: { ...VARIABLE_PROPERTIES } },
|
||||||
|
title: "ucas var list result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/template-set",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
schemaHash: { type: "string", format: "cas_ref" },
|
||||||
|
contentHash: { type: "string", format: "cas_ref" },
|
||||||
|
},
|
||||||
|
title: "ucas template set result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/template-get",
|
||||||
|
{ type: "string", title: "ucas template get result" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/template-list",
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
schemaHash: { type: "string", format: "cas_ref" },
|
||||||
|
contentHash: { type: "string", format: "cas_ref" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: "ucas template list result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/template-delete",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { deleted: { type: "boolean" } },
|
||||||
|
title: "ucas template delete result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/gc",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
total: { type: "number" },
|
||||||
|
reachable: { type: "number" },
|
||||||
|
collected: { type: "number" },
|
||||||
|
scanned: { type: "number" },
|
||||||
|
},
|
||||||
|
title: "ucas gc result",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write the meta-schema seed node into the store.
|
* Write the meta-schema seed node into the store and register built-in schemas.
|
||||||
* The returned hash equals the node's own type field (self-referencing).
|
* The returned object contains aliases for the meta-schema, 5 primitive schemas,
|
||||||
* Idempotent: calling bootstrap multiple times returns the same hash.
|
* and 18 @output/* schemas (24 total).
|
||||||
|
* Idempotent: calling bootstrap multiple times returns the same hashes.
|
||||||
*/
|
*/
|
||||||
export async function bootstrap(store: Store): Promise<Hash> {
|
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
||||||
if (!isBootstrapCapableStore(store)) {
|
if (!isBootstrapCapableStore(store)) {
|
||||||
throw new Error("Store does not support bootstrap");
|
throw new Error("Store does not support bootstrap");
|
||||||
}
|
}
|
||||||
return store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
|
||||||
|
// 1. Bootstrap the meta-schema (self-referential)
|
||||||
|
const metaHash = await store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
||||||
|
|
||||||
|
// 2. Register built-in primitive schemas directly (without putSchema to avoid recursion)
|
||||||
|
const stringHash = await store.put(metaHash, { type: "string" });
|
||||||
|
const numberHash = await store.put(metaHash, { type: "number" });
|
||||||
|
const objectHash = await store.put(metaHash, { type: "object" });
|
||||||
|
const arrayHash = await store.put(metaHash, { type: "array" });
|
||||||
|
const boolHash = await store.put(metaHash, { type: "boolean" });
|
||||||
|
|
||||||
|
// 3. 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,9 +197,17 @@ describe("createMemoryStore – listByType", () => {
|
|||||||
|
|
||||||
test("bootstrap node is listed under its self type", async () => {
|
test("bootstrap node is listed under its self type", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const hash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const hash = builtinSchemas["@schema"] ?? "";
|
||||||
|
|
||||||
expect(store.listByType(hash)).toEqual([hash]);
|
// All built-in schemas should be typed by the meta-schema
|
||||||
|
const allTypedByMeta = store.listByType(hash);
|
||||||
|
expect(allTypedByMeta).toContain(hash); // meta-schema itself
|
||||||
|
expect(allTypedByMeta).toContain(builtinSchemas["@string"] ?? "");
|
||||||
|
expect(allTypedByMeta).toContain(builtinSchemas["@number"] ?? "");
|
||||||
|
expect(allTypedByMeta).toContain(builtinSchemas["@object"] ?? "");
|
||||||
|
expect(allTypedByMeta).toContain(builtinSchemas["@array"] ?? "");
|
||||||
|
expect(allTypedByMeta).toContain(builtinSchemas["@bool"] ?? "");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,44 +264,61 @@ describe("bootstrap", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns a valid 13-char hash", async () => {
|
test("returns a map with 26 built-in schema aliases", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const hash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
expect(hash).toHaveLength(13);
|
|
||||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
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(26);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("node is stored and retrievable", async () => {
|
test("meta-schema node is stored and retrievable", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const hash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
|
|
||||||
expect(store.has(hash)).toBe(true);
|
expect(store.has(metaHash)).toBe(true);
|
||||||
const node = store.get(hash);
|
const node = store.get(metaHash);
|
||||||
expect(node).not.toBeNull();
|
expect(node).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("node is self-referencing: type === hash", async () => {
|
test("meta-schema node is self-referencing: type === hash", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const hash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
const node = store.get(hash) as CasNode;
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
|
const node = store.get(metaHash) as CasNode;
|
||||||
|
|
||||||
expect(node.type).toBe(hash);
|
expect(node.type).toBe(metaHash);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bootstrap node passes verify()", async () => {
|
test("bootstrap node passes verify()", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const hash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
const node = store.get(hash) as CasNode;
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
|
const node = store.get(metaHash) as CasNode;
|
||||||
|
|
||||||
expect(await verify(hash, node)).toBe(true);
|
expect(await verify(metaHash, node)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bootstrap is idempotent: same hash on repeated calls", async () => {
|
test("bootstrap is idempotent: same hashes on repeated calls", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const h1 = await bootstrap(store);
|
const h1 = await bootstrap(store);
|
||||||
const h2 = await bootstrap(store);
|
const h2 = await bootstrap(store);
|
||||||
|
|
||||||
expect(h1).toBe(h2);
|
expect(h1).toEqual(h2);
|
||||||
expect(store.listByType(h1)).toHaveLength(1);
|
// All 26 built-in schemas should be typed by the meta-schema
|
||||||
|
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(26);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
|||||||
export { cborEncode } from "./cbor.js";
|
export { cborEncode } from "./cbor.js";
|
||||||
export { type GcStats, gc } from "./gc.js";
|
export { type GcStats, gc } from "./gc.js";
|
||||||
export { computeHash, computeSelfHash } from "./hash.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 { JSONSchema } from "./schema.js";
|
export type { JSONSchema } from "./schema.js";
|
||||||
export {
|
export {
|
||||||
getSchema,
|
getSchema,
|
||||||
@@ -27,3 +35,4 @@ export {
|
|||||||
VariableStore,
|
VariableStore,
|
||||||
} from "./variable-store.js";
|
} from "./variable-store.js";
|
||||||
export { verify } from "./verify.js";
|
export { verify } from "./verify.js";
|
||||||
|
export { wrapEnvelope } from "./wrap-envelope.js";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -31,6 +31,14 @@ export class MemStore implements BootstrapCapableStore {
|
|||||||
return this.#inner.listAll();
|
return this.#inner.listAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listMeta(): Hash[] {
|
||||||
|
return this.#inner.listMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
listSchemas(): Hash[] {
|
||||||
|
return this.#inner.listSchemas();
|
||||||
|
}
|
||||||
|
|
||||||
delete(hash: Hash): void {
|
delete(hash: Hash): void {
|
||||||
this.#inner.delete(hash);
|
this.#inner.delete(hash);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { bootstrap } from "./bootstrap.js";
|
||||||
|
import { registerOutputTemplates } from "./output-templates.js";
|
||||||
|
import { createMemoryStore } from "./store.js";
|
||||||
|
import type { Store } from "./types.js";
|
||||||
|
import type { VariableStore } from "./variable-store.js";
|
||||||
|
import { createVariableStore } from "./variable-store.js";
|
||||||
|
|
||||||
|
const OUTPUT_ALIASES = [
|
||||||
|
"@output/put",
|
||||||
|
"@output/get",
|
||||||
|
"@output/has",
|
||||||
|
"@output/hash",
|
||||||
|
"@output/verify",
|
||||||
|
"@output/refs",
|
||||||
|
"@output/walk",
|
||||||
|
"@output/list",
|
||||||
|
"@output/var-set",
|
||||||
|
"@output/var-get",
|
||||||
|
"@output/var-delete",
|
||||||
|
"@output/var-tag",
|
||||||
|
"@output/var-list",
|
||||||
|
"@output/template-set",
|
||||||
|
"@output/template-get",
|
||||||
|
"@output/template-list",
|
||||||
|
"@output/template-delete",
|
||||||
|
"@output/gc",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("registerOutputTemplates", () => {
|
||||||
|
let store: Store;
|
||||||
|
let varStore: VariableStore;
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
varStore.close();
|
||||||
|
await rm(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers a template for every @output/* schema", async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||||
|
store = createMemoryStore();
|
||||||
|
await bootstrap(store);
|
||||||
|
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||||
|
|
||||||
|
const registered = await registerOutputTemplates(store, varStore);
|
||||||
|
|
||||||
|
expect(Object.keys(registered)).toHaveLength(18);
|
||||||
|
|
||||||
|
for (const alias of OUTPUT_ALIASES) {
|
||||||
|
expect(registered).toHaveProperty(alias);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each template is retrievable via @ucas/template/text/<hash>", async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||||
|
store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||||
|
|
||||||
|
await registerOutputTemplates(store, varStore);
|
||||||
|
|
||||||
|
const stringHash = aliases["@string"];
|
||||||
|
if (!stringHash) throw new Error("@string not found");
|
||||||
|
|
||||||
|
for (const alias of OUTPUT_ALIASES) {
|
||||||
|
const schemaHash = aliases[alias];
|
||||||
|
if (!schemaHash) throw new Error(`${alias} not found`);
|
||||||
|
|
||||||
|
const varName = `@ucas/template/text/${schemaHash}`;
|
||||||
|
const variable = varStore.get(varName, stringHash);
|
||||||
|
if (variable === null) throw new Error(`Variable ${varName} not found`);
|
||||||
|
|
||||||
|
const templateNode = store.get(variable.value);
|
||||||
|
if (templateNode === null)
|
||||||
|
throw new Error(`Template node ${variable.value} not found`);
|
||||||
|
expect(typeof templateNode.payload).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is idempotent — safe to call multiple times", async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||||
|
store = createMemoryStore();
|
||||||
|
await bootstrap(store);
|
||||||
|
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||||
|
|
||||||
|
const first = await registerOutputTemplates(store, varStore);
|
||||||
|
const second = await registerOutputTemplates(store, varStore);
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@output/put template contains payload reference", async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
|
||||||
|
store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||||
|
|
||||||
|
await registerOutputTemplates(store, varStore);
|
||||||
|
|
||||||
|
const putHash = aliases["@output/put"];
|
||||||
|
if (!putHash) throw new Error("@output/put not found");
|
||||||
|
const stringHash = aliases["@string"];
|
||||||
|
if (!stringHash) throw new Error("@string not found");
|
||||||
|
|
||||||
|
const variable = varStore.get(`@ucas/template/text/${putHash}`, stringHash);
|
||||||
|
if (variable === null)
|
||||||
|
throw new Error("@output/put template variable not found");
|
||||||
|
|
||||||
|
const templateNode = store.get(variable.value);
|
||||||
|
if (templateNode === null) throw new Error("Template node not found");
|
||||||
|
expect(templateNode.payload).toBe("{{ payload }}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { bootstrap } from "./bootstrap.js";
|
||||||
|
import type { Hash, Store } from "./types.js";
|
||||||
|
import type { VariableStore } from "./variable-store.js";
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATES: ReadonlyArray<
|
||||||
|
readonly [alias: string, template: string]
|
||||||
|
> = [
|
||||||
|
["@output/put", "{{ payload }}"],
|
||||||
|
[
|
||||||
|
"@output/get",
|
||||||
|
"type: {{ payload.type }}\ntimestamp: {{ payload.timestamp }}",
|
||||||
|
],
|
||||||
|
["@output/has", "{{ payload }}"],
|
||||||
|
["@output/hash", "{{ payload }}"],
|
||||||
|
["@output/verify", "{{ payload }}"],
|
||||||
|
["@output/refs", "{% for ref in payload %}{{ ref }}\n{% endfor %}"],
|
||||||
|
["@output/walk", "{% for item in payload %}{{ item }}\n{% endfor %}"],
|
||||||
|
["@output/list", "{% for item in payload %}{{ item }}\n{% endfor %}"],
|
||||||
|
[
|
||||||
|
"@output/var-set",
|
||||||
|
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-get",
|
||||||
|
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-delete",
|
||||||
|
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-tag",
|
||||||
|
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/var-list",
|
||||||
|
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@output/template-set",
|
||||||
|
"schemaHash: {{ payload.schemaHash }}\ncontentHash: {{ payload.contentHash }}",
|
||||||
|
],
|
||||||
|
["@output/template-get", "{{ payload }}"],
|
||||||
|
[
|
||||||
|
"@output/template-list",
|
||||||
|
"{% for t in payload %}schemaHash: {{ t.schemaHash }}\ncontentHash: {{ t.contentHash }}\n{% endfor %}",
|
||||||
|
],
|
||||||
|
["@output/template-delete", "deleted: {{ payload.deleted }}"],
|
||||||
|
[
|
||||||
|
"@output/gc",
|
||||||
|
"total: {{ payload.total }}\nreachable: {{ payload.reachable }}\ncollected: {{ payload.collected }}\nscanned: {{ payload.scanned }}",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register default LiquidJS templates for all @output/* schemas.
|
||||||
|
* Each template is stored as a @string CAS node and bound to
|
||||||
|
* the variable `@ucas/template/text/<schema-hash>`.
|
||||||
|
*
|
||||||
|
* Idempotent: safe to call multiple times.
|
||||||
|
*/
|
||||||
|
export async function registerOutputTemplates(
|
||||||
|
store: Store,
|
||||||
|
varStore: VariableStore,
|
||||||
|
): Promise<Record<string, Hash>> {
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const stringHash = aliases["@string"];
|
||||||
|
if (stringHash === undefined) {
|
||||||
|
throw new Error("@string schema not found in bootstrap result");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered: Record<string, Hash> = {};
|
||||||
|
|
||||||
|
for (const [alias, template] of DEFAULT_TEMPLATES) {
|
||||||
|
const schemaHash = aliases[alias];
|
||||||
|
if (schemaHash === undefined) {
|
||||||
|
throw new Error(`Schema alias not found: ${alias}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentHash = await store.put(stringHash, template);
|
||||||
|
const varName = `@ucas/template/text/${schemaHash}`;
|
||||||
|
varStore.set(varName, contentHash);
|
||||||
|
registered[alias] = contentHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return registered;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
|||||||
|
import { renderWithTemplate } from "./liquid-render.js";
|
||||||
|
import { collectRefs, getSchema, putSchema, refs } from "./schema.js";
|
||||||
|
import type { Hash, Store } from "./types.js";
|
||||||
|
import type { VariableStore } from "./variable-store.js";
|
||||||
|
import { CasNodeNotFoundError } from "./variable-store.js";
|
||||||
|
|
||||||
|
export type RenderOptions = {
|
||||||
|
resolution?: number; // (0, 1], default 1.0
|
||||||
|
decay?: number; // (0, 1], default 0.5
|
||||||
|
epsilon?: number; // >= 0, default 0.01
|
||||||
|
varStore?: VariableStore; // Optional: for template lookup
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_RESOLUTION = 1.0;
|
||||||
|
const DEFAULT_DECAY = 0.5;
|
||||||
|
const DEFAULT_EPSILON = 0.01;
|
||||||
|
// Small tolerance for floating point comparison
|
||||||
|
const FLOAT_TOLERANCE = 1e-10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and validate resolution/decay/epsilon from options.
|
||||||
|
*/
|
||||||
|
function validateAndExtractOptions(
|
||||||
|
options:
|
||||||
|
| Pick<RenderOptions, "resolution" | "decay" | "epsilon">
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
): { resolution: number; decay: number; epsilon: number } {
|
||||||
|
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
|
||||||
|
const decay = options?.decay ?? DEFAULT_DECAY;
|
||||||
|
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
hash: Hash,
|
||||||
|
currentResolution: number,
|
||||||
|
decay: number,
|
||||||
|
epsilon: number,
|
||||||
|
visited: Set<Hash>,
|
||||||
|
): string {
|
||||||
|
// Check if resolution is below threshold (with floating point tolerance)
|
||||||
|
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
|
||||||
|
return `cas:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the node
|
||||||
|
const node = store !== null ? store.get(hash) : null;
|
||||||
|
if (node === null) {
|
||||||
|
// Missing node - render as cas: reference
|
||||||
|
return `cas:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle detection
|
||||||
|
if (visited.has(hash)) {
|
||||||
|
return `cas:${hash}`;
|
||||||
|
}
|
||||||
|
visited.add(hash);
|
||||||
|
|
||||||
|
// Get references from this node's schema
|
||||||
|
const nodeRefs = store !== null ? refs(store, node) : [];
|
||||||
|
const refSet = new Set(nodeRefs);
|
||||||
|
|
||||||
|
// Calculate child resolution for next level
|
||||||
|
const childResolution = currentResolution * decay;
|
||||||
|
|
||||||
|
// Render the payload with recursive expansion of cas_ref fields
|
||||||
|
const rendered = renderValue(
|
||||||
|
store,
|
||||||
|
node.payload,
|
||||||
|
refSet,
|
||||||
|
childResolution,
|
||||||
|
decay,
|
||||||
|
epsilon,
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
|
||||||
|
visited.delete(hash);
|
||||||
|
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderValue(
|
||||||
|
store: Store | null,
|
||||||
|
value: unknown,
|
||||||
|
refHashes: Set<Hash>,
|
||||||
|
childResolution: number,
|
||||||
|
decay: number,
|
||||||
|
epsilon: number,
|
||||||
|
visited: Set<Hash>,
|
||||||
|
): string {
|
||||||
|
// Handle null
|
||||||
|
if (value === null) {
|
||||||
|
return "null\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle primitives
|
||||||
|
if (typeof value === "string") {
|
||||||
|
// Check if this string is a cas_ref
|
||||||
|
if (refHashes.has(value as Hash)) {
|
||||||
|
// Recursively render the referenced node
|
||||||
|
return renderNode(
|
||||||
|
store,
|
||||||
|
value as Hash,
|
||||||
|
childResolution,
|
||||||
|
decay,
|
||||||
|
epsilon,
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Otherwise, render as YAML string
|
||||||
|
return toYamlString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return `${value}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle arrays
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return "[]\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = value.map((item) => {
|
||||||
|
const itemYaml = renderValue(
|
||||||
|
store,
|
||||||
|
item,
|
||||||
|
refHashes,
|
||||||
|
childResolution,
|
||||||
|
decay,
|
||||||
|
epsilon,
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
return indent(itemYaml.trim(), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
return `- ${items.join("\n- ")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle objects
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return "{}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairs = keys.map((key) => {
|
||||||
|
const val = obj[key];
|
||||||
|
const valYaml = renderValue(
|
||||||
|
store,
|
||||||
|
val,
|
||||||
|
refHashes,
|
||||||
|
childResolution,
|
||||||
|
decay,
|
||||||
|
epsilon,
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
|
||||||
|
const trimmedVal = valYaml.trim();
|
||||||
|
|
||||||
|
// If value is multiline, indent it
|
||||||
|
if (trimmedVal.includes("\n")) {
|
||||||
|
return `${key}:\n${indent(trimmedVal, 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${key}: ${trimmedVal}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${pairs.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "null\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toYamlString(str: string): string {
|
||||||
|
// Handle special characters
|
||||||
|
if (
|
||||||
|
str.includes("\n") ||
|
||||||
|
str.includes(":") ||
|
||||||
|
str.includes("#") ||
|
||||||
|
str.includes("[") ||
|
||||||
|
str.includes("]") ||
|
||||||
|
str.includes("{") ||
|
||||||
|
str.includes("}") ||
|
||||||
|
str.includes("'") ||
|
||||||
|
str.includes('"') ||
|
||||||
|
str.startsWith(" ") ||
|
||||||
|
str.endsWith(" ")
|
||||||
|
) {
|
||||||
|
// Use double-quoted string with escaping
|
||||||
|
const escaped = str
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\n/g, "\\n");
|
||||||
|
return `"${escaped}"\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${str}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indent(text: string, spaces: number): string {
|
||||||
|
const prefix = " ".repeat(spaces);
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => (line ? prefix + line : line))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
@@ -29,7 +29,8 @@ describe("putSchema", () => {
|
|||||||
|
|
||||||
test("schema node type equals the meta-schema hash", async () => {
|
test("schema node type equals the meta-schema hash", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
const schemaHash = await putSchema(store, { type: "string" });
|
const schemaHash = await putSchema(store, { type: "string" });
|
||||||
const node = store.get(schemaHash) as CasNode;
|
const node = store.get(schemaHash) as CasNode;
|
||||||
|
|
||||||
@@ -355,7 +356,8 @@ describe("walk", () => {
|
|||||||
describe("bootstrap meta-schema self-reference", () => {
|
describe("bootstrap meta-schema self-reference", () => {
|
||||||
test("metaNode.type === metaHash (self-referencing)", async () => {
|
test("metaNode.type === metaHash (self-referencing)", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
const metaNode = store.get(metaHash) as CasNode;
|
const metaNode = store.get(metaHash) as CasNode;
|
||||||
|
|
||||||
expect(metaNode.type).toBe(metaHash);
|
expect(metaNode.type).toBe(metaHash);
|
||||||
@@ -363,7 +365,8 @@ describe("bootstrap meta-schema self-reference", () => {
|
|||||||
|
|
||||||
test("schema nodes have type === metaHash", async () => {
|
test("schema nodes have type === metaHash", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
const schemaHash = await putSchema(store, { type: "string" });
|
const schemaHash = await putSchema(store, { type: "string" });
|
||||||
const schemaNode = store.get(schemaHash) as CasNode;
|
const schemaNode = store.get(schemaHash) as CasNode;
|
||||||
|
|
||||||
@@ -372,7 +375,8 @@ describe("bootstrap meta-schema self-reference", () => {
|
|||||||
|
|
||||||
test("data nodes have type === schemaHash (not metaHash)", async () => {
|
test("data nodes have type === schemaHash (not metaHash)", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
const schemaHash = await putSchema(store, {
|
const schemaHash = await putSchema(store, {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { val: { type: "number" } },
|
properties: { val: { type: "number" } },
|
||||||
@@ -384,9 +388,111 @@ describe("bootstrap meta-schema self-reference", () => {
|
|||||||
expect(dataNode.type).not.toBe(metaHash);
|
expect(dataNode.type).not.toBe(metaHash);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── P1 leaf constraints ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("accepts schema with numeric constraints (minimum/maximum)", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 100,
|
||||||
|
exclusiveMinimum: -1,
|
||||||
|
exclusiveMaximum: 101,
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
// validate a conforming payload
|
||||||
|
const nodeHash = await store.put(hash, 42);
|
||||||
|
const node = store.get(nodeHash) as CasNode;
|
||||||
|
expect(validate(store, node)).toBe(true);
|
||||||
|
|
||||||
|
// validate a non-conforming payload
|
||||||
|
const badHash = await store.put(hash, 200);
|
||||||
|
const badNode = store.get(badHash) as CasNode;
|
||||||
|
expect(validate(store, badNode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with string constraints (minLength/maxLength/pattern)", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "string",
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 10,
|
||||||
|
pattern: "^[a-z]+$",
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
const goodHash = await store.put(hash, "hello");
|
||||||
|
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
|
||||||
|
|
||||||
|
const badHash = await store.put(hash, "HELLO");
|
||||||
|
expect(validate(store, store.get(badHash) as CasNode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with array constraints (minItems/maxItems/uniqueItems)", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "number" },
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 3,
|
||||||
|
uniqueItems: true,
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
const goodHash = await store.put(hash, [1, 2, 3]);
|
||||||
|
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
|
||||||
|
|
||||||
|
const tooMany = await store.put(hash, [1, 2, 3, 4]);
|
||||||
|
expect(validate(store, store.get(tooMany) as CasNode)).toBe(false);
|
||||||
|
|
||||||
|
const dupes = await store.put(hash, [1, 1]);
|
||||||
|
expect(validate(store, store.get(dupes) as CasNode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects schema with wrong constraint types", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
await expect(
|
||||||
|
putSchema(store, { type: "number", minimum: "zero" } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
putSchema(store, { type: "string", maxLength: true } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
putSchema(store, { type: "array", uniqueItems: 1 } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with nested property constraints", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", minLength: 1, maxLength: 50 },
|
||||||
|
age: { type: "number", minimum: 0, maximum: 150 },
|
||||||
|
scores: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "number" },
|
||||||
|
minItems: 1,
|
||||||
|
uniqueItems: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
const good = await store.put(hash, {
|
||||||
|
name: "Alice",
|
||||||
|
age: 30,
|
||||||
|
scores: [95, 87],
|
||||||
|
});
|
||||||
|
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("bootstrap is idempotent across putSchema calls", async () => {
|
test("bootstrap is idempotent across putSchema calls", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
|
|
||||||
await putSchema(store, { type: "string" });
|
await putSchema(store, { type: "string" });
|
||||||
await putSchema(store, { type: "number" });
|
await putSchema(store, { type: "number" });
|
||||||
@@ -395,4 +501,253 @@ describe("bootstrap meta-schema self-reference", () => {
|
|||||||
const metaNode = store.get(metaHash) as CasNode;
|
const metaNode = store.get(metaHash) as CasNode;
|
||||||
expect(metaNode.type).toBe(metaHash);
|
expect(metaNode.type).toBe(metaHash);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── P2 combinators, conditionals, and leaf constraints ──────────────────
|
||||||
|
|
||||||
|
test("accepts schema with allOf", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
allOf: [
|
||||||
|
{ type: "object", properties: { name: { type: "string" } } },
|
||||||
|
{ required: ["name"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
const good = await store.put(hash, { name: "Alice" });
|
||||||
|
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||||
|
|
||||||
|
const bad = await store.put(hash, {});
|
||||||
|
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with if/then/else", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
kind: { type: "string" },
|
||||||
|
value: {},
|
||||||
|
},
|
||||||
|
if: { properties: { kind: { const: "number" } } },
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable
|
||||||
|
then: { properties: { value: { type: "number" } } },
|
||||||
|
else: { properties: { value: { type: "string" } } },
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with patternProperties", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "object",
|
||||||
|
patternProperties: {
|
||||||
|
"^x-": { type: "string" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
const good = await store.put(hash, { "x-custom": "hello" });
|
||||||
|
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with prefixItems (tuple)", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "array",
|
||||||
|
prefixItems: [{ type: "string" }, { type: "number" }],
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with multipleOf", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "number",
|
||||||
|
multipleOf: 5,
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
const good = await store.put(hash, 15);
|
||||||
|
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||||
|
|
||||||
|
const bad = await store.put(hash, 7);
|
||||||
|
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with minProperties/maxProperties", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "object",
|
||||||
|
minProperties: 1,
|
||||||
|
maxProperties: 3,
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
const good = await store.put(hash, { a: 1, b: 2 });
|
||||||
|
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||||
|
|
||||||
|
const empty = await store.put(hash, {});
|
||||||
|
expect(validate(store, store.get(empty) as CasNode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with default value", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "string",
|
||||||
|
default: "hello",
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid P2 keyword types", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
await expect(
|
||||||
|
putSchema(store, { allOf: "not-array" } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
putSchema(store, { multipleOf: "five" } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
putSchema(store, { patternProperties: [1, 2] } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collectRefs traverses allOf sub-schemas", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const innerSchema = await putSchema(store, { type: "string" });
|
||||||
|
const schema = await putSchema(store, {
|
||||||
|
allOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { ref: { type: "string", format: "cas_ref" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetHash = await store.put(innerSchema, "target");
|
||||||
|
const nodeHash = await store.put(schema, { ref: targetHash });
|
||||||
|
const node = store.get(nodeHash) as CasNode;
|
||||||
|
const refList = refs(store, node);
|
||||||
|
expect(refList).toContain(targetHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collectRefs traverses patternProperties", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const innerSchema = await putSchema(store, { type: "string" });
|
||||||
|
const schema = await putSchema(store, {
|
||||||
|
type: "object",
|
||||||
|
patternProperties: {
|
||||||
|
"^ref_": { type: "string", format: "cas_ref" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetHash = await store.put(innerSchema, "hello");
|
||||||
|
const nodeHash = await store.put(schema, { ref_a: targetHash });
|
||||||
|
const node = store.get(nodeHash) as CasNode;
|
||||||
|
const refList = refs(store, node);
|
||||||
|
expect(refList).toContain(targetHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collectRefs traverses prefixItems", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const innerSchema = await putSchema(store, { type: "string" });
|
||||||
|
const schema = await putSchema(store, {
|
||||||
|
type: "array",
|
||||||
|
prefixItems: [{ type: "string", format: "cas_ref" }, { type: "number" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetHash = await store.put(innerSchema, "hello");
|
||||||
|
const nodeHash = await store.put(schema, [targetHash, 42]);
|
||||||
|
const node = store.get(nodeHash) as CasNode;
|
||||||
|
const refList = refs(store, node);
|
||||||
|
expect(refList).toContain(targetHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── P3 combinators, propertyNames, and metadata ──────────────────────────
|
||||||
|
|
||||||
|
test("accepts schema with not", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
not: { type: "string" },
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
const good = await store.put(hash, 42);
|
||||||
|
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||||
|
|
||||||
|
const bad = await store.put(hash, "hello");
|
||||||
|
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with contains", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "array",
|
||||||
|
contains: { type: "number", minimum: 10 },
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
const good = await store.put(hash, [1, 2, 15]);
|
||||||
|
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||||
|
|
||||||
|
const bad = await store.put(hash, [1, 2, 3]);
|
||||||
|
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with propertyNames", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "object",
|
||||||
|
propertyNames: { pattern: "^[a-z]+$" },
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
|
||||||
|
const good = await store.put(hash, { foo: 1, bar: 2 });
|
||||||
|
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||||
|
|
||||||
|
const bad = await store.put(hash, { Foo: 1 });
|
||||||
|
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts schema with metadata keywords", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await putSchema(store, {
|
||||||
|
type: "string",
|
||||||
|
examples: ["hello", "world"],
|
||||||
|
readOnly: true,
|
||||||
|
deprecated: false,
|
||||||
|
$comment: "This is a test schema",
|
||||||
|
});
|
||||||
|
expect(hash).toHaveLength(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid P3 keyword types", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
await expect(
|
||||||
|
putSchema(store, { not: "not-object" } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
putSchema(store, { examples: "not-array" } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
putSchema(store, { readOnly: "yes" } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(putSchema(store, { $comment: 42 } as never)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collectRefs traverses contains", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const innerSchema = await putSchema(store, { type: "string" });
|
||||||
|
const schema = await putSchema(store, {
|
||||||
|
type: "array",
|
||||||
|
contains: { type: "string", format: "cas_ref" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetHash = await store.put(innerSchema, "hello");
|
||||||
|
const nodeHash = await store.put(schema, [targetHash, "not-a-ref"]);
|
||||||
|
const node = store.get(nodeHash) as CasNode;
|
||||||
|
const refList = refs(store, node);
|
||||||
|
expect(refList).toContain(targetHash);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,39 @@ const ALLOWED_SCHEMA_KEYS = new Set([
|
|||||||
"enum",
|
"enum",
|
||||||
"const",
|
"const",
|
||||||
"description",
|
"description",
|
||||||
|
// P1 leaf constraints (no collectRefs impact)
|
||||||
|
"minimum",
|
||||||
|
"maximum",
|
||||||
|
"exclusiveMinimum",
|
||||||
|
"exclusiveMaximum",
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
"minItems",
|
||||||
|
"maxItems",
|
||||||
|
"uniqueItems",
|
||||||
|
// P2 combinators + conditionals (need collectRefs)
|
||||||
|
"allOf",
|
||||||
|
"if",
|
||||||
|
"then",
|
||||||
|
"else",
|
||||||
|
"patternProperties",
|
||||||
|
"prefixItems",
|
||||||
|
// P2 leaf constraints
|
||||||
|
"multipleOf",
|
||||||
|
"minProperties",
|
||||||
|
"maxProperties",
|
||||||
|
"default",
|
||||||
|
// P3 combinators (need collectRefs)
|
||||||
|
"not",
|
||||||
|
"contains",
|
||||||
|
"propertyNames",
|
||||||
|
// P3 metadata (no collectRefs impact)
|
||||||
|
"examples",
|
||||||
|
"readOnly",
|
||||||
|
"writeOnly",
|
||||||
|
"deprecated",
|
||||||
|
"$comment",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const JSON_SCHEMA_TYPES = new Set([
|
const JSON_SCHEMA_TYPES = new Set([
|
||||||
@@ -126,6 +159,83 @@ function isValidSchema(value: unknown): boolean {
|
|||||||
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
|
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P1 leaf constraints — type checks only
|
||||||
|
if ("minimum" in schema && typeof schema.minimum !== "number") return false;
|
||||||
|
if ("maximum" in schema && typeof schema.maximum !== "number") return false;
|
||||||
|
if (
|
||||||
|
"exclusiveMinimum" in schema &&
|
||||||
|
typeof schema.exclusiveMinimum !== "number"
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
"exclusiveMaximum" in schema &&
|
||||||
|
typeof schema.exclusiveMaximum !== "number"
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if ("minLength" in schema && typeof schema.minLength !== "number")
|
||||||
|
return false;
|
||||||
|
if ("maxLength" in schema && typeof schema.maxLength !== "number")
|
||||||
|
return false;
|
||||||
|
if ("pattern" in schema && typeof schema.pattern !== "string") return false;
|
||||||
|
if ("minItems" in schema && typeof schema.minItems !== "number") return false;
|
||||||
|
if ("maxItems" in schema && typeof schema.maxItems !== "number") return false;
|
||||||
|
if ("uniqueItems" in schema && typeof schema.uniqueItems !== "boolean")
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// P2 combinators + conditionals — recursive sub-schema checks
|
||||||
|
if ("allOf" in schema) {
|
||||||
|
if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) return false;
|
||||||
|
for (const entry of schema.allOf) {
|
||||||
|
if (!isValidSchema(entry)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("if" in schema && !isValidSchema(schema.if)) return false;
|
||||||
|
if ("then" in schema && !isValidSchema(schema.then)) return false;
|
||||||
|
if ("else" in schema && !isValidSchema(schema.else)) return false;
|
||||||
|
|
||||||
|
if ("patternProperties" in schema) {
|
||||||
|
const pp = schema.patternProperties;
|
||||||
|
if (pp === null || typeof pp !== "object" || Array.isArray(pp))
|
||||||
|
return false;
|
||||||
|
for (const nested of Object.values(pp as Record<string, unknown>)) {
|
||||||
|
if (!isValidSchema(nested)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("prefixItems" in schema) {
|
||||||
|
if (!Array.isArray(schema.prefixItems) || schema.prefixItems.length === 0)
|
||||||
|
return false;
|
||||||
|
for (const entry of schema.prefixItems) {
|
||||||
|
if (!isValidSchema(entry)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2 leaf constraints — type checks only
|
||||||
|
if ("multipleOf" in schema && typeof schema.multipleOf !== "number")
|
||||||
|
return false;
|
||||||
|
if ("minProperties" in schema && typeof schema.minProperties !== "number")
|
||||||
|
return false;
|
||||||
|
if ("maxProperties" in schema && typeof schema.maxProperties !== "number")
|
||||||
|
return false;
|
||||||
|
// "default" accepts any value — no type check needed
|
||||||
|
|
||||||
|
// P3 combinators — recursive sub-schema checks
|
||||||
|
if ("not" in schema && !isValidSchema(schema.not)) return false;
|
||||||
|
if ("contains" in schema && !isValidSchema(schema.contains)) return false;
|
||||||
|
if ("propertyNames" in schema && !isValidSchema(schema.propertyNames))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// P3 metadata — type checks only
|
||||||
|
if ("examples" in schema && !Array.isArray(schema.examples)) return false;
|
||||||
|
if ("readOnly" in schema && typeof schema.readOnly !== "boolean")
|
||||||
|
return false;
|
||||||
|
if ("writeOnly" in schema && typeof schema.writeOnly !== "boolean")
|
||||||
|
return false;
|
||||||
|
if ("deprecated" in schema && typeof schema.deprecated !== "boolean")
|
||||||
|
return false;
|
||||||
|
if ("$comment" in schema && typeof schema.$comment !== "string") return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +252,11 @@ export async function putSchema(
|
|||||||
store: Store,
|
store: Store,
|
||||||
jsonSchema: JSONSchema,
|
jsonSchema: JSONSchema,
|
||||||
): Promise<Hash> {
|
): Promise<Hash> {
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"];
|
||||||
|
if (!metaHash) {
|
||||||
|
throw new Error("Meta-schema not found in bootstrap result");
|
||||||
|
}
|
||||||
if (!isValidSchema(jsonSchema)) {
|
if (!isValidSchema(jsonSchema)) {
|
||||||
throw new SchemaValidationError(
|
throw new SchemaValidationError(
|
||||||
"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema",
|
"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema",
|
||||||
@@ -179,10 +293,11 @@ export function validate(store: Store, node: CasNode): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively collect values of all properties whose schema has format: 'cas_ref'.
|
* Recursively collect values of all properties whose schema has format: 'cas_ref'.
|
||||||
* Handles: direct format, anyOf (nullable refs), items (array refs),
|
* Handles: direct format, anyOf/allOf (combinators), oneOf, if/then/else (conditionals),
|
||||||
* properties (nested objects), and additionalProperties (record refs).
|
* not, contains, items + prefixItems (arrays), properties (nested objects),
|
||||||
|
* additionalProperties (record refs), and patternProperties (regex-keyed refs).
|
||||||
*/
|
*/
|
||||||
function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||||
const result: Hash[] = [];
|
const result: Hash[] = [];
|
||||||
|
|
||||||
if (schema.format === "cas_ref") {
|
if (schema.format === "cas_ref") {
|
||||||
@@ -199,11 +314,55 @@ function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schema.type === "array" && schema.items && Array.isArray(value)) {
|
// P2: allOf — each sub-schema applies to the same value
|
||||||
const itemSchema = schema.items as JSONSchema;
|
if (Array.isArray(schema.allOf)) {
|
||||||
for (const item of value as unknown[]) {
|
for (const sub of schema.allOf as JSONSchema[]) {
|
||||||
result.push(...collectRefs(itemSchema, item));
|
result.push(...collectRefs(sub, value));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2: if/then/else — conditional sub-schemas apply to the same value
|
||||||
|
if (schema.if && typeof schema.if === "object") {
|
||||||
|
result.push(...collectRefs(schema.if as JSONSchema, value));
|
||||||
|
}
|
||||||
|
if (schema.then && typeof schema.then === "object") {
|
||||||
|
result.push(...collectRefs(schema.then as JSONSchema, value));
|
||||||
|
}
|
||||||
|
if (schema.else && typeof schema.else === "object") {
|
||||||
|
result.push(...collectRefs(schema.else as JSONSchema, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === "array" && Array.isArray(value)) {
|
||||||
|
// P2: prefixItems — tuple validation, each item has its own schema
|
||||||
|
if (Array.isArray(schema.prefixItems)) {
|
||||||
|
const tupleSchemas = schema.prefixItems as JSONSchema[];
|
||||||
|
const arr = value as unknown[];
|
||||||
|
for (let i = 0; i < tupleSchemas.length && i < arr.length; i++) {
|
||||||
|
const ts = tupleSchemas[i];
|
||||||
|
if (ts) result.push(...collectRefs(ts, arr[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.items) {
|
||||||
|
const itemSchema = schema.items as JSONSchema;
|
||||||
|
// When prefixItems exists, items applies only to remaining elements
|
||||||
|
const startIdx = Array.isArray(schema.prefixItems)
|
||||||
|
? (schema.prefixItems as unknown[]).length
|
||||||
|
: 0;
|
||||||
|
const arr = value as unknown[];
|
||||||
|
for (let i = startIdx; i < arr.length; i++) {
|
||||||
|
result.push(...collectRefs(itemSchema, arr[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P3: contains — sub-schema for array items
|
||||||
|
if (schema.contains && typeof schema.contains === "object") {
|
||||||
|
const containsSchema = schema.contains as JSONSchema;
|
||||||
|
for (const item of value as unknown[]) {
|
||||||
|
result.push(...collectRefs(containsSchema, item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +385,28 @@ function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
|||||||
result.push(...collectRefs(addlSchema, val));
|
result.push(...collectRefs(addlSchema, val));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P2: patternProperties — regex-keyed property schemas
|
||||||
|
if (
|
||||||
|
schema.patternProperties &&
|
||||||
|
typeof schema.patternProperties === "object"
|
||||||
|
) {
|
||||||
|
const pp = schema.patternProperties as Record<string, JSONSchema>;
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
for (const [pat, subSchema] of Object.entries(pp)) {
|
||||||
|
const re = new RegExp(pat);
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
if (re.test(key)) {
|
||||||
|
result.push(...collectRefs(subSchema, val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P3: not — sub-schema applies to the same value
|
||||||
|
if (schema.not && typeof schema.not === "object") {
|
||||||
|
result.push(...collectRefs(schema.not as JSONSchema, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||||
|
import { createMemoryStore } from "./store.js";
|
||||||
|
|
||||||
|
describe("createMemoryStore – meta and schema indexes", () => {
|
||||||
|
test("B1. listMeta and listSchemas are empty on a fresh store", () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
expect(store.listMeta()).toEqual([]);
|
||||||
|
expect(store.listSchemas()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B2. self-referencing put adds hash to metaSet and listSchemas", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||||
|
|
||||||
|
expect(store.listMeta()).toContain(hash);
|
||||||
|
expect(store.listSchemas()).toContain(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B3. regular put does not add hash to metaSet", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const metaHash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||||
|
const schemaHash = await store.put(metaHash, { type: "string" });
|
||||||
|
|
||||||
|
expect(store.listMeta()).not.toContain(schemaHash);
|
||||||
|
expect(store.listMeta()).toContain(metaHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B4. schema typed by meta-schema appears in listSchemas", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||||
|
const s = await store.put(m, { type: "string" });
|
||||||
|
|
||||||
|
const schemas = store.listSchemas();
|
||||||
|
expect(schemas).toContain(m);
|
||||||
|
expect(schemas).toContain(s);
|
||||||
|
|
||||||
|
const meta = store.listMeta();
|
||||||
|
expect(meta).toContain(m);
|
||||||
|
expect(meta).not.toContain(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B5. multiple meta-schemas (versioning)", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const m1 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v1" });
|
||||||
|
const m2 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v2" });
|
||||||
|
const s1 = await store.put(m1, { type: "string" });
|
||||||
|
const s2 = await store.put(m2, { type: "number" });
|
||||||
|
|
||||||
|
const meta = store.listMeta();
|
||||||
|
expect(meta).toContain(m1);
|
||||||
|
expect(meta).toContain(m2);
|
||||||
|
expect(meta).toHaveLength(2);
|
||||||
|
|
||||||
|
const schemas = store.listSchemas();
|
||||||
|
expect(schemas).toContain(m1);
|
||||||
|
expect(schemas).toContain(m2);
|
||||||
|
expect(schemas).toContain(s1);
|
||||||
|
expect(schemas).toContain(s2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B6. idempotent self-referencing put does not duplicate", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const payload = { type: "object", title: "dup" };
|
||||||
|
const h1 = await store[BOOTSTRAP_STORE](payload);
|
||||||
|
const h2 = await store[BOOTSTRAP_STORE](payload);
|
||||||
|
expect(h1).toBe(h2);
|
||||||
|
|
||||||
|
const meta = store.listMeta();
|
||||||
|
const occurrences = meta.filter((h) => h === h1).length;
|
||||||
|
expect(occurrences).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B7. delete removes hash from metaSet and listSchemas", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||||
|
const s = await store.put(m, { type: "string" });
|
||||||
|
|
||||||
|
expect(store.listMeta()).toContain(m);
|
||||||
|
expect(store.listSchemas()).toContain(s);
|
||||||
|
|
||||||
|
store.delete(m);
|
||||||
|
|
||||||
|
expect(store.listMeta()).not.toContain(m);
|
||||||
|
// schemas typed by deleted meta no longer surface
|
||||||
|
expect(store.listSchemas()).not.toContain(s);
|
||||||
|
expect(store.listSchemas()).not.toContain(m);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import type { CasNode, Hash } from "./types.js";
|
|||||||
export function createMemoryStore(): BootstrapCapableStore {
|
export function createMemoryStore(): BootstrapCapableStore {
|
||||||
const data = new Map<Hash, CasNode>();
|
const data = new Map<Hash, CasNode>();
|
||||||
const byType = new Map<Hash, Set<Hash>>();
|
const byType = new Map<Hash, Set<Hash>>();
|
||||||
|
const metaSet = new Set<Hash>();
|
||||||
|
|
||||||
function indexHash(type: Hash, hash: Hash): void {
|
function indexHash(type: Hash, hash: Hash): void {
|
||||||
let set = byType.get(type);
|
let set = byType.get(type);
|
||||||
@@ -24,6 +25,7 @@ export function createMemoryStore(): BootstrapCapableStore {
|
|||||||
data.set(hash, { type: hash, payload, timestamp: Date.now() });
|
data.set(hash, { type: hash, payload, timestamp: Date.now() });
|
||||||
indexHash(hash, hash);
|
indexHash(hash, hash);
|
||||||
}
|
}
|
||||||
|
metaSet.add(hash);
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,22 @@ export function createMemoryStore(): BootstrapCapableStore {
|
|||||||
return Array.from(data.keys());
|
return Array.from(data.keys());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listMeta(): Hash[] {
|
||||||
|
return Array.from(metaSet);
|
||||||
|
},
|
||||||
|
|
||||||
|
listSchemas(): Hash[] {
|
||||||
|
const result = new Set<Hash>();
|
||||||
|
for (const meta of metaSet) {
|
||||||
|
result.add(meta);
|
||||||
|
const set = byType.get(meta);
|
||||||
|
if (set) {
|
||||||
|
for (const h of set) result.add(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(result);
|
||||||
|
},
|
||||||
|
|
||||||
delete(hash: Hash): void {
|
delete(hash: Hash): void {
|
||||||
const node = data.get(hash);
|
const node = data.get(hash);
|
||||||
if (node) {
|
if (node) {
|
||||||
@@ -68,6 +86,7 @@ export function createMemoryStore(): BootstrapCapableStore {
|
|||||||
byType.delete(node.type);
|
byType.delete(node.type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
metaSet.delete(hash);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -25,5 +25,7 @@ export type Store = {
|
|||||||
has(hash: Hash): boolean;
|
has(hash: Hash): boolean;
|
||||||
listByType(typeHash: Hash): Hash[];
|
listByType(typeHash: Hash): Hash[];
|
||||||
listAll(): Hash[];
|
listAll(): Hash[];
|
||||||
|
listMeta(): Hash[];
|
||||||
|
listSchemas(): Hash[];
|
||||||
delete(hash: Hash): void;
|
delete(hash: Hash): void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1593,3 +1593,186 @@ describe("VariableStore - Tag/Label Management", () => {
|
|||||||
varStore.close();
|
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,8 +36,11 @@ export class SchemaMismatchError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CasNodeNotFoundError extends Error {
|
export class CasNodeNotFoundError extends Error {
|
||||||
constructor(hash: string) {
|
constructor(
|
||||||
super(`CAS node not found: ${hash}`);
|
public readonly hash: string,
|
||||||
|
message?: string,
|
||||||
|
) {
|
||||||
|
super(message ?? `CAS node not found: ${hash}`);
|
||||||
this.name = "CasNodeNotFoundError";
|
this.name = "CasNodeNotFoundError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,7 @@ export class VariableStore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate variable name format
|
* Validate variable name format
|
||||||
|
* @ is allowed at the start of the first segment (system-reserved)
|
||||||
*/
|
*/
|
||||||
private validateName(name: string): void {
|
private validateName(name: string): void {
|
||||||
// Rule 1: Cannot be empty
|
// Rule 1: Cannot be empty
|
||||||
@@ -139,9 +143,10 @@ export class VariableStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ and no empty segments
|
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment)
|
||||||
const segments = name.split("/");
|
const segments = name.split("/");
|
||||||
for (const segment of segments) {
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i] as string;
|
||||||
if (segment === "") {
|
if (segment === "") {
|
||||||
throw new InvalidVariableNameError(
|
throw new InvalidVariableNameError(
|
||||||
name,
|
name,
|
||||||
@@ -150,10 +155,12 @@ export class VariableStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for invalid characters
|
// Check for invalid characters
|
||||||
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
|
// First segment can start with @, all segments can contain [a-zA-Z0-9._-]
|
||||||
|
const regex = i === 0 ? /^@?[a-zA-Z0-9._-]+$/ : /^[a-zA-Z0-9._-]+$/;
|
||||||
|
if (!regex.test(segment)) {
|
||||||
throw new InvalidVariableNameError(
|
throw new InvalidVariableNameError(
|
||||||
name,
|
name,
|
||||||
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
|
`Segment "${segment}" contains invalid characters (only ${i === 0 ? "@, " : ""}a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { bootstrap } from "./bootstrap.js";
|
||||||
|
import { createMemoryStore } from "./store.js";
|
||||||
|
import { wrapEnvelope } from "./wrap-envelope.js";
|
||||||
|
|
||||||
|
describe("wrapEnvelope", () => {
|
||||||
|
test("resolves @output/put alias and returns envelope", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
|
||||||
|
const envelope = await wrapEnvelope(store, "@output/put", "AAAAAAAAAAAAA");
|
||||||
|
|
||||||
|
expect(envelope.type).toBe(aliases["@output/put"]);
|
||||||
|
expect(envelope.value).toBe("AAAAAAAAAAAAA");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves @output/has alias with boolean value", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
|
||||||
|
const envelope = await wrapEnvelope(store, "@output/has", true);
|
||||||
|
|
||||||
|
expect(envelope.type).toBe(aliases["@output/has"]);
|
||||||
|
expect(envelope.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves @output/gc alias with object value", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
|
||||||
|
const gcStats = { total: 100, reachable: 80, collected: 20, scanned: 5 };
|
||||||
|
const envelope = await wrapEnvelope(store, "@output/gc", gcStats);
|
||||||
|
|
||||||
|
expect(envelope.type).toBe(aliases["@output/gc"]);
|
||||||
|
expect(envelope.value).toEqual(gcStats);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves primitive alias @string", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
|
||||||
|
const envelope = await wrapEnvelope(store, "@string", "hello");
|
||||||
|
|
||||||
|
expect(envelope.type).toBe(aliases["@string"]);
|
||||||
|
expect(envelope.value).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws for unknown alias", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
await bootstrap(store);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
wrapEnvelope(store, "@output/nonexistent", "value"),
|
||||||
|
).rejects.toThrow("Unknown schema alias: @output/nonexistent");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is idempotent — same alias returns same type hash", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
|
||||||
|
const first = await wrapEnvelope(store, "@output/verify", "ok");
|
||||||
|
const second = await wrapEnvelope(store, "@output/verify", "corrupted");
|
||||||
|
|
||||||
|
expect(first.type).toBe(second.type);
|
||||||
|
expect(first.value).toBe("ok");
|
||||||
|
expect(second.value).toBe("corrupted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves complex object values without mutation", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
await bootstrap(store);
|
||||||
|
|
||||||
|
const original = {
|
||||||
|
name: "test",
|
||||||
|
schema: "AAAAAAAAAAAAA",
|
||||||
|
value: "BBBBBBBBBBBBB",
|
||||||
|
created: 1000,
|
||||||
|
updated: 2000,
|
||||||
|
tags: { env: "prod" },
|
||||||
|
labels: ["stable"],
|
||||||
|
};
|
||||||
|
const envelope = await wrapEnvelope(store, "@output/var-set", original);
|
||||||
|
|
||||||
|
expect(envelope.value).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { bootstrap } from "./bootstrap.js";
|
||||||
|
import type { Hash, Store } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a schema alias (e.g. "@output/put") to its hash via bootstrap,
|
||||||
|
* then return a typed envelope ready for store.put() or direct rendering.
|
||||||
|
*/
|
||||||
|
export async function wrapEnvelope(
|
||||||
|
store: Store,
|
||||||
|
schemaAlias: string,
|
||||||
|
value: unknown,
|
||||||
|
): Promise<{ type: Hash; value: unknown }> {
|
||||||
|
const aliases = await bootstrap(store);
|
||||||
|
const typeHash = aliases[schemaAlias];
|
||||||
|
if (typeHash === undefined) {
|
||||||
|
throw new Error(`Unknown schema alias: ${schemaAlias}`);
|
||||||
|
}
|
||||||
|
return { type: typeHash, value };
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ import type { CasNode } from "../src/types.js";
|
|||||||
describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||||
test("1.1: Meta-schema is a valid JSON Schema", async () => {
|
test("1.1: Meta-schema is a valid JSON Schema", async () => {
|
||||||
const store = new MemStore();
|
const store = new MemStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
const metaNode = store.get(metaHash);
|
const metaNode = store.get(metaHash);
|
||||||
|
|
||||||
expect(metaNode).not.toBeNull();
|
expect(metaNode).not.toBeNull();
|
||||||
@@ -25,7 +26,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
|||||||
|
|
||||||
test("1.2: Meta-schema self-validates", async () => {
|
test("1.2: Meta-schema self-validates", async () => {
|
||||||
const store = new MemStore();
|
const store = new MemStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
const metaNode = store.get(metaHash);
|
const metaNode = store.get(metaHash);
|
||||||
|
|
||||||
expect(metaNode).not.toBeNull();
|
expect(metaNode).not.toBeNull();
|
||||||
@@ -34,7 +36,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
|||||||
|
|
||||||
test("1.3: Meta-schema defines all supported keywords", async () => {
|
test("1.3: Meta-schema defines all supported keywords", async () => {
|
||||||
const store = new MemStore();
|
const store = new MemStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
const metaSchema = getSchema(store, metaHash);
|
const metaSchema = getSchema(store, metaHash);
|
||||||
|
|
||||||
expect(metaSchema).not.toBeNull();
|
expect(metaSchema).not.toBeNull();
|
||||||
@@ -57,7 +60,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
|||||||
|
|
||||||
test("1.4: Meta-schema does not include unsupported keywords", async () => {
|
test("1.4: Meta-schema does not include unsupported keywords", async () => {
|
||||||
const store = new MemStore();
|
const store = new MemStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
const metaSchema = getSchema(store, metaHash);
|
const metaSchema = getSchema(store, metaHash);
|
||||||
|
|
||||||
expect(metaSchema).not.toBeNull();
|
expect(metaSchema).not.toBeNull();
|
||||||
@@ -68,13 +72,34 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
|||||||
expect(properties).not.toHaveProperty("$ref");
|
expect(properties).not.toHaveProperty("$ref");
|
||||||
expect(properties).not.toHaveProperty("$id");
|
expect(properties).not.toHaveProperty("$id");
|
||||||
expect(properties).not.toHaveProperty("$defs");
|
expect(properties).not.toHaveProperty("$defs");
|
||||||
expect(properties).not.toHaveProperty("allOf");
|
|
||||||
expect(properties).not.toHaveProperty("not");
|
// P2 keywords should be present
|
||||||
|
expect(properties).toHaveProperty("allOf");
|
||||||
|
expect(properties).toHaveProperty("if");
|
||||||
|
expect(properties).toHaveProperty("then");
|
||||||
|
expect(properties).toHaveProperty("else");
|
||||||
|
expect(properties).toHaveProperty("patternProperties");
|
||||||
|
expect(properties).toHaveProperty("prefixItems");
|
||||||
|
expect(properties).toHaveProperty("multipleOf");
|
||||||
|
expect(properties).toHaveProperty("minProperties");
|
||||||
|
expect(properties).toHaveProperty("maxProperties");
|
||||||
|
expect(properties).toHaveProperty("default");
|
||||||
|
|
||||||
|
// P3 keywords should be present
|
||||||
|
expect(properties).toHaveProperty("not");
|
||||||
|
expect(properties).toHaveProperty("contains");
|
||||||
|
expect(properties).toHaveProperty("propertyNames");
|
||||||
|
expect(properties).toHaveProperty("examples");
|
||||||
|
expect(properties).toHaveProperty("readOnly");
|
||||||
|
expect(properties).toHaveProperty("writeOnly");
|
||||||
|
expect(properties).toHaveProperty("deprecated");
|
||||||
|
expect(properties).toHaveProperty("$comment");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("1.5: Meta-schema node type equals its own hash", async () => {
|
test("1.5: Meta-schema node type equals its own hash", async () => {
|
||||||
const store = new MemStore();
|
const store = new MemStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
const metaNode = store.get(metaHash);
|
const metaNode = store.get(metaHash);
|
||||||
|
|
||||||
expect(metaNode).not.toBeNull();
|
expect(metaNode).not.toBeNull();
|
||||||
@@ -443,7 +468,8 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
|||||||
test("5.1: Bootstrap hash changes (breaking change)", async () => {
|
test("5.1: Bootstrap hash changes (breaking change)", async () => {
|
||||||
// This is a documentation test - the old hash was different
|
// This is a documentation test - the old hash was different
|
||||||
const store = new MemStore();
|
const store = new MemStore();
|
||||||
const newMetaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const newMetaHash = builtinSchemas["@schema"] ?? "";
|
||||||
|
|
||||||
// The new hash should be different from the old system metadata hash
|
// The new hash should be different from the old system metadata hash
|
||||||
// We just verify it's a valid hash format
|
// We just verify it's a valid hash format
|
||||||
@@ -585,7 +611,8 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
|
|||||||
describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||||
test("7.1: Meta-schema allows recursive schema definitions", async () => {
|
test("7.1: Meta-schema allows recursive schema definitions", async () => {
|
||||||
const store = new MemStore();
|
const store = new MemStore();
|
||||||
const metaHash = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||||
const metaSchema = getSchema(store, metaHash);
|
const metaSchema = getSchema(store, metaHash);
|
||||||
|
|
||||||
expect(metaSchema).not.toBeNull();
|
expect(metaSchema).not.toBeNull();
|
||||||
|
|||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Prevent publishing packages that still reference workspace:* dependencies
|
||||||
|
if grep -q '"workspace:' package.json 2>/dev/null; then
|
||||||
|
echo "❌ Found workspace:* dependencies in package.json — cannot publish directly."
|
||||||
|
echo " Use 'changeset publish' which resolves workspace protocol automatically."
|
||||||
|
grep '"workspace:' package.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user