Compare commits

..

14 Commits

Author SHA1 Message Date
xiaoju aefd93c33e chore: Phase 1 code style fixes and missing features
Fixes #27

Changes:
1. Variable uses type instead of interface
2. Add JSON envelope output {type, value} to all CLI var commands
3. Add list method with scope prefix matching to VariableStore and CLI
4. Fix var-db path to default to <storePath>/variables.db instead of <defaultStorePath>/variables.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 06:42:00 +00:00
xiaonuo 76dab6737c Merge pull request 'feat: RFC-20 Phase 1 - Variable CRUD Operations' (#25) from fix/21-variable-crud into main 2026-05-30 06:22:44 +00:00
xiaoju cf716c5115 feat: implement RFC-20 Phase 1 variable CRUD operations
Add a complete variable system for json-cas that provides mutable named
bindings to immutable CAS nodes.

Features:
- ULID-based variable identifiers (26-char Crockford Base32)
- Hierarchical scope validation (must end with /)
- Schema validation on update (prevents type mismatches)
- SQLite persistence (~/.uncaged/json-cas/variables.db)
- CLI commands: var create, get, update, delete

Implementation:
- Core types in variable.ts (Variable, VariableId)
- VariableStore class with SQLite backend
- Custom error types (VariableNotFoundError, SchemaMismatchError, etc.)
- Comprehensive unit tests (16 tests)
- CLI integration tests (12 tests)
- All outputs use JSON format

Test coverage:
- Variable creation with scope validation
- CRUD operations (create, read, update, delete)
- Schema consistency enforcement
- Error handling for all edge cases
- Full lifecycle integration tests

All tests pass (158 total), build clean, lint clean.

Fixes #21

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 05:57:05 +00:00
xiaoju 98dc91e848 Revert "chore: normalize to bun monorepo conventions"
This reverts commit 064c9afa1e.
2026-05-29 04:45:50 +00:00
xiaoju 064c9afa1e chore: normalize to bun monorepo conventions
CI / check (push) Failing after 43s
Applied monorepo normalization:
- Updated TypeScript to use composite project references with NodeNext
- Configured Biome for linting and formatting
- Standardized package.json metadata across all packages
- Set up changesets for version management and npm publishing
- Added vitest test infrastructure to all packages
- Created Gitea Actions CI pipeline
- Added solve-issue workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-29 04:39:36 +00:00
xiaoju 1ea058a7a6 chore: update lockfile after package cleanup
Remove references to deleted json-cas-workflow package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-29 01:55:12 +00:00
xiaoju c20c6df2bf chore: version packages (0.5.3)
feat: add oneOf support to meta-schema validation

小橘 🍊
2026-05-25 13:10:49 +00:00
xiaoju b2ee62dce2 feat: add oneOf support to meta-schema and validation
- Add oneOf to BOOTSTRAP_PAYLOAD (meta-schema)
- Add oneOf to ALLOWED_SCHEMA_KEYS
- Add oneOf validation in isValidSchema
- Add test 2.7b for oneOf acceptance
- Remove oneOf from unsupported keywords test

Required by workflow's solve-issue.yaml which uses oneOf for
discriminated union frontmatter schemas.
2026-05-25 11:40:00 +00:00
xiaomo 1dacd699d5 docs: sync READMEs, remove json-cas-workflow references 2026-05-25 10:27:59 +00:00
xiaomo 0e38fd3ea9 chore: remove deprecated json-cas-workflow package
Types moved to @uncaged/workflow-protocol. npm package deprecated.
2026-05-25 10:26:03 +00:00
xiaomo e00a23dd80 docs: add READMEs for all packages, move sync-readme to docs/ 2026-05-25 09:51:54 +00:00
xiaomo d2225c8cdf chore: add sync-readme cursor rule 2026-05-25 09:46:19 +00:00
xiaomo 4d7b439aaa chore: add release script and prepublishOnly guard 2026-05-25 09:35:50 +00:00
xiaoju ccca0e60d1 chore: sync solve-issue.yaml from workflow repo
- $status discriminated union frontmatter
- Portable paths (.worktrees/ relative to repo root)
- Developer failed exit
- Reviewer rejected carries worktree field

小橘 🍊(NEKO Team)
2026-05-25 09:21:29 +00:00
33 changed files with 2424 additions and 1236 deletions
+3
View File
@@ -0,0 +1,3 @@
# Sync README
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
+108 -134
View File
@@ -9,10 +9,10 @@ roles:
- planning
procedure: |
On first run (no previous steps):
1. Read the issue and all comments from Gitea using `tea issues <number>`
2. Read project conventions (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) to understand coding standards
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number>` (skip if you already commented), then output status=insufficient_info and terminate
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
On subsequent runs (bounced back by tester with fix_spec):
@@ -21,17 +21,19 @@ roles:
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when status=ready)
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
type: object
properties:
status:
type: string
enum: [ready, insufficient_info]
plan:
type: string
required: [status]
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
@@ -39,38 +41,41 @@ roles:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
Set up variables from the current working directory:
```
REPO_ROOT=$(git rev-parse --show-toplevel)
WORKTREE_BASE=$(dirname $REPO_ROOT)/$(basename $REPO_ROOT)-worktrees
```
The repo path and other details are provided in your task prompt.
Before starting any work, set up an isolated worktree:
1. `cd $REPO_ROOT && git fetch origin` to get latest refs
2. First time (no existing branch):
- `git worktree add $WORKTREE_BASE/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd $WORKTREE_BASE/fix/<issue-number>-<short-slug> && bun install`
3. If bounced back from reviewer or tester (branch already exists):
- `cd $WORKTREE_BASE/fix/<issue-number>-<short-slug>`
1. cd into the repo path provided in your task prompt
2. `git fetch origin` to get latest refs
3. First time (no existing branch):
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
4. If bounced back from reviewer or tester (branch already exists):
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
4. ALL subsequent work must happen inside the worktree directory.
5. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
5. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
6. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
7. Write tests first based on the spec
8. Implement the code to make tests pass
9. Ensure `bun run build` passes with no errors
10. Run `bun test` to verify all tests pass
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
8. Write tests first based on the spec
9. Implement the code to make tests pass
10. Ensure `bun run build` passes with no errors
11. Run `bun test` to verify all tests pass
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
or repeated attempts fail), set $status=failed with a reason.
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
type: object
properties:
status:
type: string
enum: [done, failed]
required: [status]
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Code standards compliance check"
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
@@ -78,7 +83,7 @@ roles:
- code-review
- static-analysis
procedure: |
Find and cd into the worktree directory for this issue.
The worktree path is provided in your task prompt. cd into it first.
Before reviewing, verify the git branch:
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
@@ -90,135 +95,104 @@ roles:
4. `bunx biome check` — no lint violations
5. TypeScript strict mode — no type errors
Soft checks (review against project conventions file if present):
- Code style and naming conventions
- Module organization
- No debug logging left behind
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
- Naming conventions, module boundaries, code style
- No `console.log` in production code
- No dynamic imports in production code
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
type: object
properties:
approved:
type: boolean
required: [approved]
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
required: [$status, comments, worktree]
tester:
description: "Functional correctness verification"
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
capabilities:
- testing
procedure: |
Find and cd into the worktree directory for this issue.
The worktree path is provided in your task prompt. cd into it first.
1. Run `bun test` for automated test verification
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
- fix_code: tests fail or implementation doesn't match spec → send back to developer
- fix_spec: the spec itself is wrong or incomplete → send back to planner
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
type: object
properties:
status:
type: string
enum: [passed, fix_code, fix_spec]
required: [status]
oneOf:
- properties:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "fix_code" }
report: { type: string }
required: [$status, report]
- properties:
$status: { const: "fix_spec" }
report: { type: string }
required: [$status, report]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
Find and cd into the worktree directory for this issue.
Set up variables:
```
OWNER_REPO=$(git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/')
REPO_ROOT=$(git rev-parse --show-toplevel)
MAIN_REPO=$(cd $REPO_ROOT && git worktree list --porcelain | head -1 | sed 's/worktree //')
```
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --repo $OWNER_REPO --title "..." --description "..."`
- The `--repo` flag is required to work in worktree directories
- PR description must follow: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, log the error clearly, include PR details for manual creation, and mark success=false
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- `cd $MAIN_REPO`
- `git worktree remove $REPO_ROOT`
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
type: object
properties:
success:
type: boolean
required: [success]
conditions:
insufficientInfo:
description: "Planner determined there's not enough info to proceed"
expression: "$last('planner').status = 'insufficient_info'"
devFailed:
description: "Developer failed to implement"
expression: "$last('developer').status = 'failed'"
rejected:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
fixCode:
description: "Tester found code issues"
expression: "$last('tester').status = 'fix_code'"
fixSpec:
description: "Tester found spec issues"
expression: "$last('tester').status = 'fix_spec'"
hookFailed:
description: "Push hook failed"
expression: "$last('committer').success = false"
oneOf:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
- role: "planner"
condition: null
prompt: "Analyze the issue and produce an implementation plan."
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
- role: "$END"
condition: "insufficientInfo"
prompt: "Insufficient information to proceed; end the workflow."
- role: "developer"
condition: null
prompt: "Implement the plan from the planner."
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
developer:
- role: "$END"
condition: "devFailed"
prompt: "Development failed; end the workflow."
- role: "reviewer"
condition: null
prompt: "Send the implementation to the reviewer."
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
- role: "developer"
condition: "rejected"
prompt: "Reviewer rejected the implementation; fix the issues."
- role: "tester"
condition: null
prompt: "Review passed; run tests on the implementation."
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
tester:
- role: "developer"
condition: "fixCode"
prompt: "Tests found code issues; return to developer."
- role: "planner"
condition: "fixSpec"
prompt: "Tests found spec issues; return to planner."
- role: "committer"
condition: null
prompt: "Tests passed; commit and push the changes."
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
- role: "developer"
condition: "hookFailed"
prompt: "Push hook failed; return to developer to fix."
- role: "$END"
condition: null
prompt: "Commit succeeded; complete the workflow."
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
+4 -1
View File
@@ -10,7 +10,6 @@ Monorepo with 4 packages under `packages/`:
|---------|-------------|
| `json-cas` | Core CAS engine — hashing, schema, store, verify, bootstrap |
| `json-cas-fs` | Filesystem-backed CAS store |
| `json-cas-workflow` | Workflow integration layer (schemas + types) |
| `cli-json-cas` | CLI tool |
## Tech Stack
@@ -67,6 +66,10 @@ bun run format # Biome format (auto-fix)
- Reference issues: `Fixes #N` / `Closes #N`
- Author: `小橘 <xiaoju@shazhou.work>`
## Project Rules
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
## Before Submitting
1. `bun test` — all tests pass
+133 -1
View File
@@ -1,3 +1,135 @@
# json-cas
Self-describing content-addressable storage with JSON Schema typed nodes
Self-describing content-addressable storage with JSON Schema typed nodes.
## Overview
json-cas is a monorepo for storing and validating JSON data in a content-addressable store (CAS). Each node has a typed payload: its `type` field is the hash of a JSON Schema node that describes the payload shape. Hashes are 13-character Crockford Base32 strings derived from XXH64 over deterministic CBOR encoding.
A bootstrap meta-schema is stored as a self-referencing seed node (`type === hash`). All other schemas are registered as nodes typed by that meta-schema. Payloads can reference other nodes via `format: "cas_ref"` fields; the library provides traversal, reference extraction, and integrity verification.
Use the in-memory store for tests and embedded apps, the filesystem store for persistence, and the CLI for local store management.
## Architecture
```
┌─────────────────┐
│ cli-json-cas │
└────────┬────────┘
┌─────────────────┐
│ json-cas-fs │
└────────┬────────┘
┌─────────────────┐
│ json-cas │ (core)
└─────────────────┘
```
| Layer | Package | Role |
|-------|---------|------|
| Core | `@uncaged/json-cas` | Hashing, schemas, stores, verify, bootstrap |
| Storage | `@uncaged/json-cas-fs` | Filesystem-backed `Store` |
| CLI | `@uncaged/cli-json-cas` | `json-cas` command-line tool |
## Packages
| Package | Description | Type |
|---------|-------------|------|
| [`@uncaged/json-cas`](packages/json-cas/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
| [`@uncaged/json-cas-fs`](packages/json-cas-fs/README.md) | Filesystem-backed CAS store | lib |
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`json-cas` binary) | cli |
## Quick Start
```bash
git clone <repo-url>
cd json-cas
bun install --no-cache
bun run build
```
```typescript
import {
bootstrap,
createMemoryStore,
putSchema,
validate,
} from "@uncaged/json-cas";
const store = createMemoryStore();
await bootstrap(store);
const typeHash = await putSchema(store, {
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
additionalProperties: false,
});
const hash = await store.put(typeHash, { message: "hello" });
const node = store.get(hash);
console.log(validate(store, node!)); // true
```
For a persistent store:
```typescript
import { createFsStore } from "@uncaged/json-cas-fs";
import { bootstrap } from "@uncaged/json-cas";
const store = createFsStore("/path/to/store");
await bootstrap(store);
```
Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/README.md`](packages/cli-json-cas/README.md)).
## CLI Reference
Binary: `json-cas` (from `@uncaged/cli-json-cas`). Default store: `~/.uncaged/json-cas`.
```
Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
init Create store dir and write bootstrap seed
bootstrap Write meta-schema seed, print hash
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
schema validate <hash> Validate node against its schema
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--json Compact JSON output
```
## Development
```bash
bun install --no-cache # install workspace dependencies
bun run build # tsc --build (libs)
bun run check # biome check
bun run format # biome format --write
bun test # run all package tests
```
## Publishing
Releases use [Changesets](https://github.com/changesets/changesets). From the repo root:
```bash
bun run release # changeset version → build → publish to npm (@uncaged/*)
```
Individual packages block `prepublishOnly` and expect releases via the workspace `release` script.
+8 -12
View File
@@ -10,11 +10,12 @@
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0",
"ulidx": "^2.4.1",
},
},
"packages/cli-json-cas": {
"name": "@uncaged/cli-json-cas",
"version": "0.5.0",
"version": "0.5.3",
"bin": {
"json-cas": "./src/index.ts",
},
@@ -25,7 +26,7 @@
},
"packages/json-cas": {
"name": "@uncaged/json-cas",
"version": "0.5.0",
"version": "0.5.3",
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
@@ -34,19 +35,12 @@
},
"packages/json-cas-fs": {
"name": "@uncaged/json-cas-fs",
"version": "0.5.0",
"version": "0.5.3",
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"cborg": "^4.2.3",
},
},
"packages/json-cas-workflow": {
"name": "@uncaged/json-cas-workflow",
"version": "0.5.0",
"dependencies": {
"@uncaged/json-cas": "workspace:^",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
@@ -127,8 +121,6 @@
"@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@workspace:packages/json-cas-fs"],
"@uncaged/json-cas-workflow": ["@uncaged/json-cas-workflow@workspace:packages/json-cas-workflow"],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
@@ -209,6 +201,8 @@
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
"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=="],
@@ -291,6 +285,8 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ulidx": ["ulidx@2.4.1", "", { "dependencies": { "layerr": "^3.0.0" } }, "sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+68
View File
@@ -0,0 +1,68 @@
# Sync README
When updating README.md files in this monorepo, follow these conventions.
## Scope
- Root `README.md` — project overview and navigation hub
- Per-package `packages/*/README.md` — each package self-contained
## Root README Structure
The root README should have these sections in order:
1. **Title and one-liner** — content-addressed storage for JSON with schema validation
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
3. **Architecture** — dependency layer diagram (text-based)
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib)
5. **Quick Start** — install, build, basic usage
6. **CLI Reference** — brief command list, detailed usage in cli-json-cas README
7. **Development** — bun install / build / check / test
8. **Publishing** — changeset workflow (bun run release)
## Per-Package README Structure
Each package README should have:
1. **Title** — package name
2. **One-line description** — matching package.json
3. **Overview** — what it does, where it sits in the architecture, dependencies
4. **Installation** — bun add (for libs) or "included as binary" (for cli)
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
6. **CLI Usage** (cli packages) — command reference with examples
7. **Internal Structure** — brief src/ file organization
8. **Configuration** (if applicable)
## Execution Steps
### Step 1: Gather current state
For each package read:
- package.json (name, version, description, dependencies, bin)
- src/index.ts (public API exports)
- Existing README.md (preserve hand-written content worth keeping)
### Step 2: Update root README
- Ensure ALL packages in packages/ directory are listed in the table
- Update CLI command reference from actual --help output
- Keep Quick Start examples valid
### Step 3: Write/update each package README
- Follow the per-package structure
- API section MUST match actual src/index.ts exports — never invent
- For cli packages: document CLI binary name, how it is invoked
- For lib packages: document exported types and functions
- Internal structure: list actual files in src/
### Step 4: Verify
- All relative links work
- Package names match package.json
- No references to removed/renamed packages
- bun run build still passes
## Guidelines
- Only document what src/index.ts actually exports
- Root README summarizes, package READMEs go into detail
- Verify CLI examples against actual commands
- Preserve existing good prose when updating
- English for all README content
+5 -3
View File
@@ -9,12 +9,14 @@
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"ulidx": "^2.4.1"
},
"scripts": {
"build": "tsc --build packages/json-cas packages/json-cas-fs packages/json-cas-workflow",
"build": "tsc --build packages/json-cas packages/json-cas-fs",
"test": "bun test",
"check": "biome check .",
"format": "biome format --write ."
"format": "biome format --write .",
"release": "changeset version && bun run build && changeset publish"
}
}
+8
View File
@@ -1,5 +1,13 @@
# @uncaged/cli-json-cas
## 0.5.3
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.5.3
- @uncaged/json-cas-fs@0.5.3
## 0.3.0
### Patch Changes
+98
View File
@@ -0,0 +1,98 @@
# @uncaged/cli-json-cas
CLI tool for json-cas stores.
## Overview
`@uncaged/cli-json-cas` provides the `json-cas` command for managing a filesystem-backed store: bootstrap, schema registration, node CRUD, integrity checks, reference listing, and graph walks. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
## Installation
Published as an npm package with a binary entry:
```bash
bun add -g @uncaged/cli-json-cas
# or from the monorepo workspace:
bun link
```
**Binary name:** `json-cas` (points to `src/index.ts`, run with Bun).
In development:
```bash
bun packages/cli-json-cas/src/index.ts <command> [args]
```
## CLI Usage
```
Usage: json-cas [--store <path>] [--json] <command> [args]
```
### Global flags
| Flag | Description |
|------|-------------|
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
| `--json` | Compact JSON output for commands that print JSON |
### Commands
| Command | Description |
|---------|-------------|
| `init` | Create store directory and write bootstrap seed; prints meta hash |
| `bootstrap` | Write meta-schema seed into existing store; prints hash |
| `schema put <file.json>` | Register schema from file; prints type hash |
| `schema get <type-hash>` | Print schema JSON |
| `schema list` | List all schemas (`hash name`) |
| `schema validate <hash>` | Validate node against its schema; prints `valid` / `invalid` |
| `put <type-hash> <file.json>` | Store node; prints content hash |
| `get <hash>` | Print full node as JSON |
| `has <hash>` | Print `true` or `false` |
| `verify <hash>` | Verify integrity; prints `ok` or `corrupted` |
| `refs <hash>` | Print direct `cas_ref` targets (one per line) |
| `walk <hash>` | BFS traversal; one hash per line |
| `walk <hash> --format tree` | Tree-formatted traversal |
| `hash <type-hash> <file.json>` | Compute hash without storing |
| `cat <hash>` | Print node JSON |
| `cat <hash> --payload` | Print payload only |
### Examples
```bash
# Initialize default store at ~/.uncaged/json-cas
json-cas init
# Use a custom store path
json-cas --store ./data/cas bootstrap
# Register a schema and store a payload
json-cas schema put ./schemas/item.json
# → prints type hash, e.g. 0123456789ABCD
json-cas put 0123456789ABCD ./payloads/item.json
# → prints content hash
json-cas get <content-hash> --json
json-cas verify <content-hash>
json-cas walk <content-hash> --format tree
```
## Internal Structure
| File | Purpose |
|------|---------|
| `index.ts` | Argument parsing, command dispatch, and all CLI logic |
There is no separate `src/` module tree; the CLI is a single entry file. Tests (if present) are co-located under the package.
## Configuration
| Setting | Default | Override |
|---------|---------|----------|
| Store directory | `~/.uncaged/json-cas` | `--store <path>` |
No config file is read; all behavior is controlled via flags and command arguments.
+5 -4
View File
@@ -1,15 +1,16 @@
{
"name": "@uncaged/cli-json-cas",
"version": "0.5.0",
"version": "0.5.3",
"type": "module",
"bin": {
"json-cas": "./src/index.ts"
},
"scripts": {
"test": "bun test"
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"@uncaged/json-cas-fs": "workspace:^"
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3"
}
}
+195 -2
View File
@@ -3,13 +3,18 @@
import { mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
import {
bootstrap,
CasNodeNotFoundError,
computeHash,
createVariableStore,
getSchema,
InvalidScopeError,
putSchema,
refs,
SchemaMismatchError,
VariableNotFoundError,
validate,
verify,
walk,
@@ -21,7 +26,7 @@ import { createFsStore } from "@uncaged/json-cas-fs";
type Flags = Record<string, string | boolean>;
/** Flags that consume the next token as their value. All others are boolean. */
const VALUE_FLAGS = new Set(["store", "format"]);
const VALUE_FLAGS = new Set(["store", "format", "scope", "value", "var-db"]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
const flags: Flags = {};
@@ -57,6 +62,10 @@ const storePath =
typeof flags.store === "string" ? flags.store : defaultStorePath;
const compact = flags.json === true;
const defaultVarDbPath = join(storePath, "variables.db");
const varDbPath =
typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath;
// ---- Helpers ----
function out(data: unknown): void {
@@ -80,6 +89,52 @@ function openStore(): Store {
return createFsStore(resolve(storePath));
}
function openVarStore(): VariableStore {
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
return createVariableStore(resolve(varDbPath), store);
}
/**
* Get the Variable schema's CAS hash
* This is the type hash used in JSON envelopes
*/
async function getVariableSchemaHash(): Promise<Hash> {
const store = openStore();
// Define the Variable JSON Schema (simple version for envelope)
const variableSchema: JSONSchema = {
title: "Variable",
type: "object",
properties: {
id: { type: "string" },
scope: { type: "string" },
value: { type: "string" },
schema: { type: "string" },
created: { type: "number" },
updated: { type: "number" },
},
required: ["id", "scope", "value", "schema", "created", "updated"],
};
// Compute hash or retrieve from store
const hash = await putSchema(store, variableSchema);
return hash;
}
/**
* Wrap Variable output in JSON envelope
*/
async function wrapVariableEnvelope(
variable: unknown,
): Promise<{ type: Hash; value: unknown }> {
const typeHash = await getVariableSchemaHash();
return {
type: typeHash,
value: variable,
};
}
// ---- Commands ----
async function cmdInit(): Promise<void> {
@@ -250,6 +305,114 @@ async function cmdCat(args: string[]): Promise<void> {
}
}
async function cmdVarCreate(_args: string[]): Promise<void> {
const scope = flags.scope as string | undefined;
const value = flags.value as string | undefined;
if (!scope) die("Usage: json-cas var create --scope <scope> --value <hash>");
if (!value) die("Usage: json-cas var create --scope <scope> --value <hash>");
const varStore = openVarStore();
try {
const variable = varStore.create(scope, value);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (e instanceof InvalidScopeError || e instanceof CasNodeNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarGet(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var get <id>");
const varStore = openVarStore();
try {
const variable = varStore.get(id);
if (variable === null) {
die(`Error: Variable not found: ${id}`);
}
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} finally {
varStore.close();
}
}
async function cmdVarUpdate(args: string[]): Promise<void> {
const id = args[0];
const value = args[1];
if (!id || !value) {
die("Usage: json-cas var update <id> <hash>");
}
const varStore = openVarStore();
try {
const variable = varStore.update(id, value);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
e instanceof SchemaMismatchError ||
e instanceof CasNodeNotFoundError
) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarDelete(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var delete <id>");
const varStore = openVarStore();
try {
const variable = varStore.delete(id);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarList(_args: string[]): Promise<void> {
const scope = (flags.scope as string | undefined) ?? "";
const varStore = openVarStore();
try {
const variables = varStore.list({ scope });
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
} catch (e) {
if (e instanceof InvalidScopeError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
@@ -269,9 +432,15 @@ Commands:
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
var create --scope <s> --value <h> Create a variable
var get <id> Get a variable by ID
var update <id> <hash> Update variable value
var delete <id> Delete a variable
var list [--scope <prefix>] List variables (optionally filter by scope prefix)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output`);
}
@@ -346,6 +515,30 @@ switch (cmd) {
await cmdCat(rest);
break;
case "var": {
const [sub, ...subRest] = rest;
switch (sub) {
case "create":
await cmdVarCreate(subRest);
break;
case "get":
await cmdVarGet(subRest);
break;
case "update":
await cmdVarUpdate(subRest);
break;
case "delete":
await cmdVarDelete(subRest);
break;
case "list":
await cmdVarList(subRest);
break;
default:
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
}
break;
}
default:
die(`Unknown command: ${cmd}`);
}
+822
View File
@@ -0,0 +1,822 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
describe("CLI var commands", () => {
let storePath: string;
let varDbPath: string;
let cliPath: string;
let schemaHash: string;
let hashA: string;
let hashB: string;
let testCounter = 0;
beforeEach(async () => {
// Create temporary paths with counter to ensure uniqueness
testCounter++;
storePath = join(tmpdir(), `test-cli-store-${Date.now()}-${testCounter}`);
varDbPath = join(storePath, "variables.db");
cliPath = join(import.meta.dir, "index.ts");
// Initialize store and create test data
const initResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "init"],
{
encoding: "utf-8",
},
);
expect(initResult.status).toBe(0);
// Create a schema
const schemaFile = join(tmpdir(), `schema-${Date.now()}.json`);
await Bun.write(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaPutResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "schema", "put", schemaFile],
{ encoding: "utf-8" },
);
expect(schemaPutResult.status).toBe(0);
schemaHash = schemaPutResult.stdout.trim();
// Create test CAS nodes
const dataFileA = join(tmpdir(), `data-a-${Date.now()}.json`);
await Bun.write(dataFileA, JSON.stringify({ name: "hello" }));
const putResultA = spawnSync(
"bun",
[cliPath, "--store", storePath, "put", schemaHash, dataFileA],
{ encoding: "utf-8" },
);
expect(putResultA.status).toBe(0);
hashA = putResultA.stdout.trim();
const dataFileB = join(tmpdir(), `data-b-${Date.now()}.json`);
await Bun.write(dataFileB, JSON.stringify({ name: "world" }));
const putResultB = spawnSync(
"bun",
[cliPath, "--store", storePath, "put", schemaHash, dataFileB],
{ encoding: "utf-8" },
);
expect(putResultB.status).toBe(0);
hashB = putResultB.stdout.trim();
});
afterEach(() => {
// Cleanup
try {
unlinkSync(varDbPath);
} catch {
// Ignore
}
});
describe("Test Group 1: Variable Creation", () => {
test("1.1: Create variable with valid scope", () => {
const result = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaHash);
expect(variable.created).toBeGreaterThan(Date.now() - 5000);
expect(variable.updated).toBe(variable.created);
});
test("1.2: Create variable fails with scope not ending in /", () => {
const result = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("scope must end with /");
});
test("1.3: Create variable fails with non-existent CAS node", () => {
const fakeHash = "FAKEHASH00000";
const result = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/",
"--value",
fakeHash,
],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("CAS node not found");
});
});
describe("Test Group 2: Variable Retrieval", () => {
test("2.1: Get existing variable", () => {
// Create a variable first
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Get the variable
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", created.id],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaHash);
});
test("2.2: Get non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", fakeId],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("Variable not found");
});
});
describe("Test Group 3: Variable Update (Schema Consistent)", () => {
test("3.1: Update variable with matching schema", async () => {
// Create a variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Wait a bit to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
// Update the variable
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "update", created.id, hashB],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
expect(variable.value).toBe(hashB);
expect(variable.schema).toBe(schemaHash);
expect(variable.updated).toBeGreaterThan(created.created);
});
});
describe("Test Group 4: Variable Update (Schema Mismatch)", () => {
test("4.1: Update variable fails with schema mismatch", async () => {
// Create another schema
const schema2File = join(tmpdir(), `schema2-${Date.now()}.json`);
await Bun.write(
schema2File,
JSON.stringify({
type: "object",
properties: { count: { type: "number" } },
}),
);
const schema2PutResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "schema", "put", schema2File],
{ encoding: "utf-8" },
);
const schemaHash2 = schema2PutResult.stdout.trim();
// Create a node with the second schema
const dataFileC = join(tmpdir(), `data-c-${Date.now()}.json`);
await Bun.write(dataFileC, JSON.stringify({ count: 42 }));
const putResultC = spawnSync(
"bun",
[cliPath, "--store", storePath, "put", schemaHash2, dataFileC],
{ encoding: "utf-8" },
);
const hashC = putResultC.stdout.trim();
// Create a variable with first schema
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Try to update with different schema
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "update", created.id, hashC],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toContain("schema mismatch");
});
});
describe("Test Group 5: Variable Deletion", () => {
test("5.1: Delete existing variable", () => {
// Create a variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Delete the variable
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "delete", created.id],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
// Verify it's deleted
const getResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", created.id],
{ encoding: "utf-8" },
);
expect(getResult.status).not.toBe(0);
});
test("5.3: Delete non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "delete", fakeId],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("Variable not found");
});
});
describe("Test Group 6: Variable Listing", () => {
test("6.1: List variables with scope prefix", () => {
// Create variables with different scopes
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult1.status).toBe(0);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
expect(createResult2.status).toBe(0);
const var2 = JSON.parse(createResult2.stdout).value;
const createResult3 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/agent/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult3.status).toBe(0);
const var3 = JSON.parse(createResult3.stdout).value;
const createResult4 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"app/config/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult4.status).toBe(0);
// List all variables with uwf/ prefix
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list", "--scope", "uwf/"],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
// Check the actual variables in the value field
const variables = output.value;
expect(variables).toHaveLength(3);
expect(
variables.every((v: { scope: string }) => v.scope.startsWith("uwf/")),
).toBe(true);
// Verify ordering by created timestamp
expect(variables[0].id).toBe(var1.id);
expect(variables[1].id).toBe(var2.id);
expect(variables[2].id).toBe(var3.id);
});
test("6.2: List all variables when no scope specified", () => {
// Create variables with different scopes
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult1.status).toBe(0);
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"app/config/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
expect(createResult2.status).toBe(0);
// List all variables without scope filter
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list"],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
const variables = output.value;
expect(variables).toHaveLength(2);
});
test("6.3: List returns empty array when no matches", () => {
// Create a variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult.status).toBe(0);
// List with non-matching scope
const listResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"list",
"--scope",
"nonexistent/",
],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
expect(output.value).toHaveLength(0);
});
test("6.4: List fails with invalid scope format", () => {
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list", "--scope", "uwf"],
{ encoding: "utf-8" },
);
expect(listResult.status).not.toBe(0);
expect(listResult.stderr).toContain("scope must end with /");
});
});
describe("Test Group 7: Integration Tests", () => {
test("7.1: Full lifecycle workflow", async () => {
// Create variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult.status).toBe(0);
const var1 = JSON.parse(createResult.stdout).value;
expect(var1.value).toBe(hashA);
// Get variable
const getResult1 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(getResult1.status).toBe(0);
const retrieved1 = JSON.parse(getResult1.stdout).value;
expect(retrieved1.value).toBe(hashA);
// Wait to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
// Update variable
const updateResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "update", var1.id, hashB],
{ encoding: "utf-8" },
);
expect(updateResult.status).toBe(0);
const updated = JSON.parse(updateResult.stdout).value;
expect(updated.value).toBe(hashB);
// Get updated variable
const getResult2 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(getResult2.status).toBe(0);
const retrieved2 = JSON.parse(getResult2.stdout).value;
expect(retrieved2.value).toBe(hashB);
// Delete variable
const deleteResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "delete", var1.id],
{ encoding: "utf-8" },
);
expect(deleteResult.status).toBe(0);
// Verify deletion
const getResult3 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(getResult3.status).not.toBe(0);
});
test("7.2: Multiple variables with same scope", () => {
// Create two variables
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
const var2 = JSON.parse(createResult2.stdout).value;
// Verify independence
expect(var1.id).not.toBe(var2.id);
const getResult1 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
const retrieved1 = JSON.parse(getResult1.stdout).value;
expect(retrieved1.value).toBe(hashA);
const getResult2 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var2.id],
{ encoding: "utf-8" },
);
const retrieved2 = JSON.parse(getResult2.stdout).value;
expect(retrieved2.value).toBe(hashB);
// Delete var1, verify var2 still exists
spawnSync("bun", [
cliPath,
"--store",
storePath,
"var",
"delete",
var1.id,
]);
const getResult2Final = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var2.id],
{ encoding: "utf-8" },
);
expect(getResult2Final.status).toBe(0);
const retrieved2Final = JSON.parse(getResult2Final.stdout).value;
expect(retrieved2Final.value).toBe(hashB);
});
test("7.3: Variables with hierarchical scopes", () => {
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var2 = JSON.parse(createResult2.stdout).value;
const createResult3 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/workflow/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var3 = JSON.parse(createResult3.stdout).value;
expect(var1.scope).toBe("uwf/");
expect(var2.scope).toBe("uwf/thread/");
expect(var3.scope).toBe("uwf/workflow/");
// Verify all exist
const get1 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(get1.status).toBe(0);
const get2 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var2.id],
{ encoding: "utf-8" },
);
expect(get2.status).toBe(0);
const get3 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var3.id],
{ encoding: "utf-8" },
);
expect(get3.status).toBe(0);
});
});
});
+13
View File
@@ -1,5 +1,18 @@
# @uncaged/json-cas-fs
## 0.5.3
### Patch Changes
- feat: add oneOf support to meta-schema validation
Added `oneOf` to `ALLOWED_SCHEMA_KEYS` and corresponding validation logic
in `isValidSchema`. This enables workflow frontmatter schemas that use
`oneOf` discriminated unions for multi-exit role definitions.
- Updated dependencies []:
- @uncaged/json-cas@0.5.3
## 0.3.0
### Minor Changes
+67
View File
@@ -0,0 +1,67 @@
# @uncaged/json-cas-fs
Filesystem-backed CAS store.
## Overview
`@uncaged/json-cas-fs` implements a persistent `Store` on disk. Each node is stored as `<hash>.bin` (CBOR-encoded `CasNode`). A `_index/` directory maps type hashes to content hashes for `listByType`. Stores support bootstrap via the same `BOOTSTRAP_STORE` symbol as the in-memory implementation.
Depends on `@uncaged/json-cas` for hashing, CBOR encoding, and types.
**Dependencies:** `@uncaged/json-cas`, `cborg`
## Installation
```bash
bun add @uncaged/json-cas-fs
```
## API
Exported from `src/index.ts`:
```typescript
function createFsStore(dir: string): BootstrapCapableStore;
```
Returns a `BootstrapCapableStore` from `@uncaged/json-cas`. The store loads existing `.bin` files on open and migrates or builds the type index on first use.
### Example
```typescript
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
const store = createFsStore("./my-cas-store");
await bootstrap(store);
const typeHash = await putSchema(store, {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
});
const hash = await store.put(typeHash, { id: "item-1" });
console.log(store.has(hash)); // true after restart if same dir
```
### On-disk layout
```
my-cas-store/
├── <hash>.bin # CBOR CasNode
├── _index/
│ └── <typeHash> # newline-separated content hashes
└── ...
```
Writes use atomic rename (`<hash>.tmp``<hash>.bin`).
## Internal Structure
| File | Purpose |
|------|---------|
| `store.ts` | `createFsStore`, load/save nodes and type index |
| `index.ts` | Public export |
| `store.test.ts` | Filesystem store tests |
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas-fs",
"version": "0.5.0",
"version": "0.5.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -15,10 +15,11 @@
"src"
],
"scripts": {
"test": "bun test"
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"@uncaged/json-cas": "^0.5.3",
"cborg": "^4.2.3"
}
}
-24
View File
@@ -1,24 +0,0 @@
# @uncaged/json-cas-workflow
## 0.3.0
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.3.0
## 0.2.0
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.2.0
## 0.1.3
### Patch Changes
- fix: replace workspace:^ with actual version numbers in published dependencies
- Updated dependencies []:
- @uncaged/json-cas@0.1.3
-23
View File
@@ -1,23 +0,0 @@
{
"name": "@uncaged/json-cas-workflow",
"version": "0.5.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "workspace:^"
}
}
@@ -1,646 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { CasNode } from "@uncaged/json-cas";
import {
createMemoryStore,
getSchema,
refs,
validate,
walk,
} from "@uncaged/json-cas";
import type { WorkflowSchemaHashes } from "./schemas.js";
import { registerWorkflowSchemas } from "./schemas.js";
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: registerWorkflowSchemas() — registers all 11 schemas
// ─────────────────────────────────────────────────────────────────────────────
describe("registerWorkflowSchemas", () => {
test("returns an object with all 11 schema hashes", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
const keys: (keyof WorkflowSchemaHashes)[] = [
"agent",
"roleSchema",
"role",
"workflow",
"threadStart",
"threadStep",
"threadEnd",
"content",
"reactSession",
"reactTurn",
"reactToolCall",
];
expect(Object.keys(hashes)).toHaveLength(11);
for (const key of keys) {
expect(hashes[key]).toBeDefined();
}
});
test("all hashes are valid 13-char Crockford Base32 strings", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
for (const hash of Object.values(hashes)) {
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("all 11 hashes are distinct", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
const values = Object.values(hashes);
const unique = new Set(values);
expect(unique.size).toBe(11);
});
test("is idempotent: repeated calls return the same hashes", async () => {
const store = createMemoryStore();
const first = await registerWorkflowSchemas(store);
const second = await registerWorkflowSchemas(store);
for (const key of Object.keys(first) as (keyof WorkflowSchemaHashes)[]) {
expect(first[key]).toBe(second[key]);
}
});
test("schemas are stored in the store (getSchema returns non-null)", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
for (const hash of Object.values(hashes)) {
expect(getSchema(store, hash)).not.toBeNull();
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: getSchema() — schema round-trip for each of the 11 types
// ─────────────────────────────────────────────────────────────────────────────
describe("getSchema round-trip", () => {
test("agent schema has the expected properties", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const schema = getSchema(store, agent);
expect(schema).not.toBeNull();
expect(schema?.type).toBe("object");
const props = schema?.properties as Record<string, unknown>;
expect(props).toHaveProperty("package");
expect(props).toHaveProperty("version");
expect(props).toHaveProperty("config");
});
test("role schema references cas_ref for the schema field", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const schema = getSchema(store, role);
expect(schema).not.toBeNull();
const props = schema?.properties as Record<string, { format?: string }>;
expect(props.schema?.format).toBe("cas_ref");
});
test("thread-step schema has six required fields", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const schema = getSchema(store, threadStep);
expect(schema?.required).toHaveLength(6);
});
test("react-turn schema has nested tokens object", async () => {
const store = createMemoryStore();
const { reactTurn } = await registerWorkflowSchemas(store);
const schema = getSchema(store, reactTurn);
const props = schema?.properties as Record<
string,
{ type: string; properties?: unknown }
>;
expect(props.tokens?.type).toBe("object");
expect(props.tokens?.properties).toBeDefined();
});
test("workflow schema has roles with additionalProperties cas_ref", async () => {
const store = createMemoryStore();
const { workflow } = await registerWorkflowSchemas(store);
const schema = getSchema(store, workflow);
const props = schema?.properties as Record<
string,
{ additionalProperties?: { format?: string } }
>;
expect(props.roles?.additionalProperties?.format).toBe("cas_ref");
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 3: validate() — correct payloads pass for all 11 schema types
// ─────────────────────────────────────────────────────────────────────────────
describe("validate – valid payloads", () => {
const HASH = "AAAAAAAAAAAAA";
test("agent payload is valid", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const h = await store.put(agent, {
package: "gpt-4o",
version: "2024-11",
config: { temperature: 0.7 },
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("role-schema payload is valid (any object)", async () => {
const store = createMemoryStore();
const { roleSchema } = await registerWorkflowSchemas(store);
const h = await store.put(roleSchema, {
type: "object",
properties: { answer: { type: "string" } },
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("role payload is valid", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const h = await store.put(role, {
name: "analyst",
description: "Analyses data",
systemPrompt: "You are an analyst.",
extractPrompt: "Extract the findings.",
schema: HASH,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("workflow payload is valid", async () => {
const store = createMemoryStore();
const { workflow } = await registerWorkflowSchemas(store);
const h = await store.put(workflow, {
name: "research",
description: "Research workflow",
roles: { analyst: HASH },
moderator: [{ from: "analyst", to: "analyst", when: null }],
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-start payload is valid (null parentThread)", async () => {
const store = createMemoryStore();
const { threadStart } = await registerWorkflowSchemas(store);
const h = await store.put(threadStart, {
workflow: HASH,
input: "hello",
depth: 0,
parentThread: null,
agents: { main: HASH },
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-start payload is valid (non-null parentThread)", async () => {
const store = createMemoryStore();
const { threadStart } = await registerWorkflowSchemas(store);
const h = await store.put(threadStart, {
workflow: HASH,
input: "nested",
depth: 1,
parentThread: HASH,
agents: {},
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-step payload is valid (null previous)", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const h = await store.put(threadStep, {
role: "analyst",
meta: { attempt: 1 },
content: HASH,
react: HASH,
start: HASH,
previous: null,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-step payload is valid (non-null previous)", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const h = await store.put(threadStep, {
role: "analyst",
meta: {},
content: HASH,
react: HASH,
start: HASH,
previous: HASH,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-end payload is valid", async () => {
const store = createMemoryStore();
const { threadEnd } = await registerWorkflowSchemas(store);
const h = await store.put(threadEnd, {
returnCode: 0,
summary: "Done",
start: HASH,
lastStep: HASH,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("content payload is valid", async () => {
const store = createMemoryStore();
const { content } = await registerWorkflowSchemas(store);
const h = await store.put(content, { text: "Hello, world!" });
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-session payload is valid (empty turns)", async () => {
const store = createMemoryStore();
const { reactSession } = await registerWorkflowSchemas(store);
const h = await store.put(reactSession, {
agent: HASH,
role: "analyst",
turns: [],
totalTokens: 0,
durationMs: 42,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-session payload is valid (multiple turns)", async () => {
const store = createMemoryStore();
const { reactSession } = await registerWorkflowSchemas(store);
const h = await store.put(reactSession, {
agent: HASH,
role: "analyst",
turns: [HASH, HASH],
totalTokens: 300,
durationMs: 1500,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-turn payload is valid", async () => {
const store = createMemoryStore();
const { reactTurn } = await registerWorkflowSchemas(store);
const h = await store.put(reactTurn, {
input: HASH,
output: HASH,
toolCalls: [HASH],
tokens: { input: 100, output: 50 },
latencyMs: 800,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-tool-call payload is valid", async () => {
const store = createMemoryStore();
const { reactToolCall } = await registerWorkflowSchemas(store);
const h = await store.put(reactToolCall, {
name: "search",
arguments: HASH,
result: HASH,
durationMs: 200,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 4: validate() — invalid payloads fail for representative types
// ─────────────────────────────────────────────────────────────────────────────
describe("validate – invalid payloads", () => {
test("agent: missing required field fails", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const h = await store.put(agent, { package: "gpt-4o", version: "1" });
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("agent: wrong type for config fails", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const h = await store.put(agent, {
package: "gpt-4o",
version: "1",
config: "not-an-object",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("role: missing systemPrompt fails", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const h = await store.put(role, {
name: "analyst",
description: "d",
extractPrompt: "e",
schema: "AAAAAAAAAAAAA",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("thread-start: missing depth fails", async () => {
const store = createMemoryStore();
const { threadStart } = await registerWorkflowSchemas(store);
const h = await store.put(threadStart, {
workflow: "AAAAAAAAAAAAA",
input: "hi",
parentThread: null,
agents: {},
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("thread-end: returnCode as string fails", async () => {
const store = createMemoryStore();
const { threadEnd } = await registerWorkflowSchemas(store);
const h = await store.put(threadEnd, {
returnCode: "ok",
summary: "Done",
start: "AAAAAAAAAAAAA",
lastStep: "AAAAAAAAAAAAA",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("content: missing text fails", async () => {
const store = createMemoryStore();
const { content } = await registerWorkflowSchemas(store);
const h = await store.put(content, {});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("react-turn: tokens.input as string fails", async () => {
const store = createMemoryStore();
const { reactTurn } = await registerWorkflowSchemas(store);
const h = await store.put(reactTurn, {
input: "AAAAAAAAAAAAA",
output: "AAAAAAAAAAAAA",
toolCalls: [],
tokens: { input: "many", output: 50 },
latencyMs: 100,
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("react-tool-call: missing durationMs fails", async () => {
const store = createMemoryStore();
const { reactToolCall } = await registerWorkflowSchemas(store);
const h = await store.put(reactToolCall, {
name: "tool",
arguments: "AAAAAAAAAAAAA",
result: "AAAAAAAAAAAAA",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 5: refs() — extracts direct cas_ref fields from node payloads
// ─────────────────────────────────────────────────────────────────────────────
describe("refs – cas_ref extraction", () => {
const HASH_A = "AAAAAAAAAAAAA";
const HASH_B = "BBBBBBBBBBBBB";
test("content node has no cas_ref fields → empty array", async () => {
const store = createMemoryStore();
const { content } = await registerWorkflowSchemas(store);
const h = await store.put(content, { text: "hello" });
const node = store.get(h) as CasNode;
expect(refs(store, node)).toEqual([]);
});
test("role node: refs() returns the schema cas_ref", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const h = await store.put(role, {
name: "r",
description: "d",
systemPrompt: "s",
extractPrompt: "e",
schema: HASH_A,
});
const node = store.get(h) as CasNode;
expect(refs(store, node)).toContain(HASH_A);
});
test("thread-end: refs() returns start and lastStep", async () => {
const store = createMemoryStore();
const { threadEnd } = await registerWorkflowSchemas(store);
const h = await store.put(threadEnd, {
returnCode: 0,
summary: "done",
start: HASH_A,
lastStep: HASH_B,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_A);
expect(result).toContain(HASH_B);
expect(result).toHaveLength(2);
});
test("react-tool-call: refs() returns arguments and result", async () => {
const store = createMemoryStore();
const { reactToolCall } = await registerWorkflowSchemas(store);
const h = await store.put(reactToolCall, {
name: "search",
arguments: HASH_A,
result: HASH_B,
durationMs: 100,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_A);
expect(result).toContain(HASH_B);
expect(result).toHaveLength(2);
});
test("thread-step: refs() returns content, react, and start (previous null is skipped)", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const h = await store.put(threadStep, {
role: "r",
meta: {},
content: HASH_A,
react: HASH_B,
start: HASH_A,
previous: null,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_A);
expect(result).toContain(HASH_B);
});
test("thread-step: refs() includes previous when non-null", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const HASH_C = "CCCCCCCCCCCCC";
const h = await store.put(threadStep, {
role: "r",
meta: {},
content: HASH_A,
react: HASH_B,
start: HASH_A,
previous: HASH_C,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_C);
});
test("react-session: refs() returns the agent cas_ref", async () => {
const store = createMemoryStore();
const { reactSession } = await registerWorkflowSchemas(store);
const h = await store.put(reactSession, {
agent: HASH_A,
role: "r",
turns: [],
totalTokens: 0,
durationMs: 0,
});
const node = store.get(h) as CasNode;
expect(refs(store, node)).toContain(HASH_A);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 6: walk() — BFS traversal through linked workflow nodes
// ─────────────────────────────────────────────────────────────────────────────
describe("walk – cross-schema traversal", () => {
test("walk visits content node linked from thread-end", async () => {
const store = createMemoryStore();
const { threadEnd, content } = await registerWorkflowSchemas(store);
const contentHash = await store.put(content, { text: "summary text" });
const endHash = await store.put(threadEnd, {
returnCode: 0,
summary: "done",
start: contentHash,
lastStep: contentHash,
});
const visited = new Set<string>();
walk(store, endHash, (h) => visited.add(h));
expect(visited.has(endHash)).toBe(true);
expect(visited.has(contentHash)).toBe(true);
});
test("walk through role → (schema stored in store)", async () => {
const store = createMemoryStore();
const { role, roleSchema } = await registerWorkflowSchemas(store);
const schemaDocHash = await store.put(roleSchema, {
type: "object",
properties: { answer: { type: "string" } },
});
const roleHash = await store.put(role, {
name: "analyst",
description: "d",
systemPrompt: "s",
extractPrompt: "e",
schema: schemaDocHash,
});
const visited = new Set<string>();
walk(store, roleHash, (h) => visited.add(h));
expect(visited.has(roleHash)).toBe(true);
expect(visited.has(schemaDocHash)).toBe(true);
});
test("walk handles diamond: two thread-end nodes sharing the same start", async () => {
const store = createMemoryStore();
const { threadEnd, content } = await registerWorkflowSchemas(store);
const sharedStart = await store.put(content, { text: "start" });
const step1 = await store.put(content, { text: "step1" });
const step2 = await store.put(content, { text: "step2" });
const end1 = await store.put(threadEnd, {
returnCode: 0,
summary: "path A",
start: sharedStart,
lastStep: step1,
});
const end2 = await store.put(threadEnd, {
returnCode: 1,
summary: "path B",
start: sharedStart,
lastStep: step2,
});
// Use react-turn as the root linking both ends via input/output
const { reactTurn } = await registerWorkflowSchemas(store);
const turnHash = await store.put(reactTurn, {
input: end1,
output: end2,
toolCalls: [],
tokens: { input: 10, output: 5 },
latencyMs: 50,
});
const visited = new Set<string>();
walk(store, turnHash, (h) => visited.add(h));
expect(visited.has(turnHash)).toBe(true);
expect(visited.has(end1)).toBe(true);
expect(visited.has(end2)).toBe(true);
// sharedStart is reached from both end1 and end2, but visited only once
expect(visited.has(sharedStart)).toBe(true);
expect(visited.has(step1)).toBe(true);
expect(visited.has(step2)).toBe(true);
});
test("walk visits react-tool-call linked from react-turn", async () => {
const store = createMemoryStore();
const { reactTurn, reactToolCall, content } =
await registerWorkflowSchemas(store);
const argsHash = await store.put(content, { text: '{"q":"test"}' });
const resultHash = await store.put(content, { text: '{"r":"ok"}' });
const toolCallHash = await store.put(reactToolCall, {
name: "search",
arguments: argsHash,
result: resultHash,
durationMs: 120,
});
const inputHash = await store.put(content, { text: "input" });
const outputHash = await store.put(content, { text: "output" });
const turnHash = await store.put(reactTurn, {
input: inputHash,
output: outputHash,
toolCalls: [],
tokens: { input: 80, output: 40 },
latencyMs: 600,
});
const visited = new Set<string>();
walk(store, turnHash, (h) => visited.add(h));
expect(visited.has(turnHash)).toBe(true);
expect(visited.has(inputHash)).toBe(true);
expect(visited.has(outputHash)).toBe(true);
// toolCallHash is not in the turn's cas_ref fields (toolCalls array), only linked manually
expect(visited.has(toolCallHash)).toBe(false);
// walk from toolCallHash to verify it reaches args and result
const tcVisited = new Set<string>();
walk(store, toolCallHash, (h) => tcVisited.add(h));
expect(tcVisited.has(toolCallHash)).toBe(true);
expect(tcVisited.has(argsHash)).toBe(true);
expect(tcVisited.has(resultHash)).toBe(true);
});
});
-19
View File
@@ -1,19 +0,0 @@
export {
registerWorkflowSchemas,
type WorkflowSchemaHashes,
} from "./schemas.js";
export type {
AgentPayload,
ContentPayload,
ReactSessionPayload,
ReactToolCallPayload,
ReactTurnPayload,
ReactTurnTokens,
RolePayload,
RoleSchemaPayload,
ThreadEndPayload,
ThreadStartPayload,
ThreadStepPayload,
WorkflowPayload,
WorkflowTransition,
} from "./types.js";
-236
View File
@@ -1,236 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { type JSONSchema, putSchema } from "@uncaged/json-cas";
// ── Definition layer ──────────────────────────────────────────────────────────
const AGENT: JSONSchema = {
type: "object",
required: ["package", "version", "config"],
properties: {
package: { type: "string" },
version: { type: "string" },
config: { type: "object" },
},
additionalProperties: false,
};
/** role-schema nodes hold raw JSON Schema documents, so any object is valid. */
const ROLE_SCHEMA: JSONSchema = {
type: "object",
};
const ROLE: JSONSchema = {
type: "object",
required: ["name", "description", "systemPrompt", "extractPrompt", "schema"],
properties: {
name: { type: "string" },
description: { type: "string" },
systemPrompt: { type: "string" },
extractPrompt: { type: "string" },
schema: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
const WORKFLOW: JSONSchema = {
type: "object",
required: ["name", "description", "roles", "moderator"],
properties: {
name: { type: "string" },
description: { type: "string" },
roles: {
type: "object",
additionalProperties: { type: "string", format: "cas_ref" },
},
moderator: {
type: "array",
items: {
type: "object",
required: ["from", "to", "when"],
properties: {
from: { type: "string" },
to: { type: "string" },
when: { anyOf: [{ type: "string" }, { type: "null" }] },
},
additionalProperties: false,
},
},
},
additionalProperties: false,
};
// ── Execution layer ───────────────────────────────────────────────────────────
const THREAD_START: JSONSchema = {
type: "object",
required: ["workflow", "input", "depth", "parentThread", "agents"],
properties: {
workflow: { type: "string", format: "cas_ref" },
input: { type: "string" },
depth: { type: "number" },
parentThread: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
agents: {
type: "object",
additionalProperties: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
const THREAD_STEP: JSONSchema = {
type: "object",
required: ["role", "meta", "content", "react", "start", "previous"],
properties: {
role: { type: "string" },
meta: { type: "object" },
content: { type: "string", format: "cas_ref" },
react: { type: "string", format: "cas_ref" },
start: { type: "string", format: "cas_ref" },
previous: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
additionalProperties: false,
};
const THREAD_END: JSONSchema = {
type: "object",
required: ["returnCode", "summary", "start", "lastStep"],
properties: {
returnCode: { type: "number" },
summary: { type: "string" },
start: { type: "string", format: "cas_ref" },
lastStep: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
const CONTENT: JSONSchema = {
type: "object",
required: ["text"],
properties: {
text: { type: "string" },
},
additionalProperties: false,
};
// ── React layer ───────────────────────────────────────────────────────────────
const REACT_SESSION: JSONSchema = {
type: "object",
required: ["agent", "role", "turns", "totalTokens", "durationMs"],
properties: {
agent: { type: "string", format: "cas_ref" },
role: { type: "string" },
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
totalTokens: { type: "number" },
durationMs: { type: "number" },
},
additionalProperties: false,
};
const REACT_TURN: JSONSchema = {
type: "object",
required: ["input", "output", "toolCalls", "tokens", "latencyMs"],
properties: {
input: { type: "string", format: "cas_ref" },
output: { type: "string", format: "cas_ref" },
toolCalls: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
tokens: {
type: "object",
required: ["input", "output"],
properties: {
input: { type: "number" },
output: { type: "number" },
},
additionalProperties: false,
},
latencyMs: { type: "number" },
},
additionalProperties: false,
};
const REACT_TOOL_CALL: JSONSchema = {
type: "object",
required: ["name", "arguments", "result", "durationMs"],
properties: {
name: { type: "string" },
arguments: { type: "string", format: "cas_ref" },
result: { type: "string", format: "cas_ref" },
durationMs: { type: "number" },
},
additionalProperties: false,
};
// ── Registry ──────────────────────────────────────────────────────────────────
export type WorkflowSchemaHashes = {
agent: Hash;
roleSchema: Hash;
role: Hash;
workflow: Hash;
threadStart: Hash;
threadStep: Hash;
threadEnd: Hash;
content: Hash;
reactSession: Hash;
reactTurn: Hash;
reactToolCall: Hash;
};
/**
* Register all 11 workflow schemas into the given store.
* Returns a map from camelCase schema name to its CAS type hash.
* Idempotent: safe to call multiple times on the same store.
*/
export async function registerWorkflowSchemas(
store: Store,
): Promise<WorkflowSchemaHashes> {
const [
agent,
roleSchema,
role,
workflow,
threadStart,
threadStep,
threadEnd,
content,
reactSession,
reactTurn,
reactToolCall,
] = await Promise.all([
putSchema(store, AGENT),
putSchema(store, ROLE_SCHEMA),
putSchema(store, ROLE),
putSchema(store, WORKFLOW),
putSchema(store, THREAD_START),
putSchema(store, THREAD_STEP),
putSchema(store, THREAD_END),
putSchema(store, CONTENT),
putSchema(store, REACT_SESSION),
putSchema(store, REACT_TURN),
putSchema(store, REACT_TOOL_CALL),
]);
return {
agent,
roleSchema,
role,
workflow,
threadStart,
threadStep,
threadEnd,
content,
reactSession,
reactTurn,
reactToolCall,
};
}
-111
View File
@@ -1,111 +0,0 @@
import type { Hash } from "@uncaged/json-cas";
// ── Definition layer ──────────────────────────────────────────────────────────
export type AgentPayload = {
package: string;
version: string;
config: Record<string, unknown>;
};
/** A JSON Schema document stored as-is. */
export type RoleSchemaPayload = Record<string, unknown>;
export type RolePayload = {
name: string;
description: string;
systemPrompt: string;
extractPrompt: string;
/** cas_ref → role-schema */
schema: Hash;
};
export type WorkflowTransition = {
from: string;
to: string;
when: string | null;
};
export type WorkflowPayload = {
name: string;
description: string;
/** cas_ref → role */
roles: Record<string, Hash>;
moderator: WorkflowTransition[];
};
// ── Execution layer ───────────────────────────────────────────────────────────
export type ThreadStartPayload = {
/** cas_ref → workflow */
workflow: Hash;
input: string;
depth: number;
/** cas_ref → thread-start | null */
parentThread: Hash | null;
/** cas_ref → agent */
agents: Record<string, Hash>;
};
export type ThreadStepPayload = {
role: string;
meta: Record<string, unknown>;
/** cas_ref → content */
content: Hash;
/** cas_ref → react-session */
react: Hash;
/** cas_ref → thread-start */
start: Hash;
/** cas_ref → thread-step | null */
previous: Hash | null;
};
export type ThreadEndPayload = {
returnCode: number;
summary: string;
/** cas_ref → thread-start */
start: Hash;
/** cas_ref → thread-step */
lastStep: Hash;
};
export type ContentPayload = {
text: string;
};
// ── React layer ───────────────────────────────────────────────────────────────
export type ReactSessionPayload = {
/** cas_ref → agent */
agent: Hash;
role: string;
/** cas_ref → react-turn */
turns: Hash[];
totalTokens: number;
durationMs: number;
};
export type ReactTurnTokens = {
input: number;
output: number;
};
export type ReactTurnPayload = {
/** cas_ref → content */
input: Hash;
/** cas_ref → content */
output: Hash;
/** cas_ref → react-tool-call */
toolCalls: Hash[];
tokens: ReactTurnTokens;
latencyMs: number;
};
export type ReactToolCallPayload = {
name: string;
/** cas_ref → content (arguments) */
arguments: Hash;
/** cas_ref → content (result) */
result: Hash;
durationMs: number;
};
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"],
"references": [{ "path": "../json-cas" }]
}
+10
View File
@@ -1,5 +1,15 @@
# @uncaged/json-cas
## 0.5.3
### Patch Changes
- feat: add oneOf support to meta-schema validation
Added `oneOf` to `ALLOWED_SCHEMA_KEYS` and corresponding validation logic
in `isValidSchema`. This enables workflow frontmatter schemas that use
`oneOf` discriminated unions for multi-exit role definitions.
## 0.3.0
### Minor Changes
+159
View File
@@ -0,0 +1,159 @@
# @uncaged/json-cas
Core CAS engine — hashing, schema, store, verify, bootstrap.
## Overview
`@uncaged/json-cas` is the foundation of the json-cas monorepo. It defines content-addressed nodes (`CasNode`), the `Store` interface, XXH64-based hashing with deterministic CBOR, JSON Schema registration and validation (including `cas_ref` links between nodes), bootstrap seeding, and integrity verification.
Other packages build on this layer: `json-cas-fs` provides persistence, and `cli-json-cas` exposes store operations on the command line.
**Dependencies:** `ajv`, `cborg`, `xxhash-wasm`
## Installation
```bash
bun add @uncaged/json-cas
```
## API
All symbols below are exported from `src/index.ts`.
### Types
```typescript
/** 13-character uppercase Crockford Base32 (XXH64) */
type Hash = string;
type CasNode<T = unknown> = {
type: Hash;
payload: T;
timestamp: number; // Unix epoch ms
};
type Store = {
put(typeHash: Hash, payload: unknown): Promise<Hash>;
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
listByType(typeHash: Hash): Hash[];
};
type JSONSchema = Record<string, unknown>;
type BootstrapCapableStore = Store & {
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash>;
};
```
### Hashing
```typescript
function computeHash(typeHash: Hash, payload: unknown): Promise<Hash>;
function computeSelfHash(payload: unknown): Promise<Hash>;
function cborEncode(value: unknown): Uint8Array;
```
`computeHash``XXH64(utf8(typeHash) ++ CBOR(payload))` for normal nodes.
`computeSelfHash``XXH64(CBOR(payload))` for bootstrap nodes where `type === hash`.
### Bootstrap
```typescript
const BOOTSTRAP_STORE: unique symbol;
async function bootstrap(store: Store): Promise<Hash>;
```
Writes the meta-schema seed node (idempotent). Requires a `BootstrapCapableStore` (e.g. from `createMemoryStore()`).
### Schema
```typescript
class SchemaValidationError extends Error;
async function putSchema(store: Store, jsonSchema: JSONSchema): Promise<Hash>;
function getSchema(store: Store, typeHash: Hash): JSONSchema | null;
function validate(store: Store, node: CasNode): boolean;
function refs(store: Store, node: CasNode): Hash[];
function walk(
store: Store,
rootHash: Hash,
visitor: (hash: Hash, node: CasNode) => void,
): void;
```
- `putSchema` — stores a schema typed by the meta-schema; returned hash is the `typeHash` for conforming payloads.
- `refs` — collects all `format: "cas_ref"` values in the payload per schema shape.
- `walk` — BFS from `rootHash`, following `cas_ref` edges; cycles are visited once.
### Store
```typescript
function createMemoryStore(): BootstrapCapableStore;
```
In-memory `Store` with type indexing, suitable for tests and ephemeral use.
### Verify
```typescript
async function verify(hash: Hash, node: CasNode): Promise<boolean>;
```
Recomputes hash from `node` and compares to `hash` (self-referencing vs normal rules).
### Example
```typescript
import {
bootstrap,
createMemoryStore,
putSchema,
refs,
validate,
walk,
} from "@uncaged/json-cas";
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const personType = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
friend: { type: "string", format: "cas_ref" },
},
required: ["name"],
additionalProperties: false,
});
const aliceHash = await store.put(personType, { name: "Alice" });
const bobHash = await store.put(personType, {
name: "Bob",
friend: aliceHash,
});
const bob = store.get(bobHash)!;
console.log(validate(store, bob)); // true
console.log(refs(store, bob)); // [aliceHash]
walk(store, bobHash, (h) => console.log(h)); // bobHash, aliceHash
```
## Internal Structure
| File | Purpose |
|------|---------|
| `types.ts` | `Hash`, `CasNode`, `Store` |
| `hash.ts` | `computeHash`, `computeSelfHash` |
| `cbor.ts` | Deterministic CBOR encoding |
| `bootstrap-capable.ts` | `BOOTSTRAP_STORE` symbol and capability check |
| `bootstrap.ts` | Meta-schema seed and `bootstrap()` |
| `store.ts` | `createMemoryStore()` |
| `mem-store.ts` | Alternate in-memory store (tests only; not exported) |
| `schema.ts` | Schema put/get/validate, `refs`, `walk` |
| `verify.ts` | Node integrity verification |
| `index.ts` | Public exports |
Tests live in `src/*.test.ts` and `tests/`.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas",
"version": "0.5.0",
"version": "0.5.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -15,7 +15,8 @@
"src"
],
"scripts": {
"test": "bun test"
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"ajv": "^8.20.0",
+4
View File
@@ -50,6 +50,10 @@ const BOOTSTRAP_PAYLOAD = {
type: "array",
items: { type: "object", additionalProperties: false },
},
oneOf: {
type: "array",
items: { type: "object", additionalProperties: false },
},
items: { type: "object", additionalProperties: false },
format: { type: "string" },
title: { type: "string" },
+9
View File
@@ -14,4 +14,13 @@ export {
} from "./schema.js";
export { createMemoryStore } from "./store.js";
export type { CasNode, Hash, Store } from "./types.js";
export type { Variable, VariableId } from "./variable.js";
export {
CasNodeNotFoundError,
createVariableStore,
InvalidScopeError,
SchemaMismatchError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
export { verify } from "./verify.js";
+8
View File
@@ -28,6 +28,7 @@ const ALLOWED_SCHEMA_KEYS = new Set([
"required",
"additionalProperties",
"anyOf",
"oneOf",
"items",
"format",
"title",
@@ -108,6 +109,13 @@ function isValidSchema(value: unknown): boolean {
}
}
if ("oneOf" in schema) {
if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) return false;
for (const entry of schema.oneOf) {
if (!isValidSchema(entry)) return false;
}
}
if ("items" in schema && !isValidSchema(schema.items)) return false;
if ("format" in schema && typeof schema.format !== "string") return false;
if ("title" in schema && typeof schema.title !== "string") return false;
@@ -0,0 +1,403 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import {
CasNodeNotFoundError,
InvalidScopeError,
SchemaMismatchError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
describe("VariableStore", () => {
let store: Store;
let varStore: VariableStore;
let dbPath: string;
let schemaA: string;
let schemaB: string;
let hashA: string;
let hashB: string;
let hashC: string;
beforeEach(async () => {
// Create a temporary database
dbPath = join(tmpdir(), `test-variables-${Date.now()}.db`);
// Create a CAS store with test data
store = createMemoryStore();
// Create two different schemas
schemaA = await store.put("BOOTSTRAPHASH", {
type: "object",
properties: { name: { type: "string" } },
});
schemaB = await store.put("BOOTSTRAPHASH", {
type: "object",
properties: { count: { type: "number" } },
});
// Create CAS nodes with different schemas
hashA = await store.put(schemaA, { name: "hello" });
hashB = await store.put(schemaA, { name: "world" });
hashC = await store.put(schemaB, { count: 42 });
// Create variable store
varStore = new VariableStore(dbPath, store);
});
afterEach(() => {
varStore.close();
try {
unlinkSync(dbPath);
} catch {
// Ignore cleanup errors
}
});
describe("Test Group 1: Variable Creation", () => {
test("1.1: Create variable with valid scope", () => {
const variable = varStore.create("uwf/thread/", hashA);
expect(variable.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaA);
expect(variable.created).toBeGreaterThan(Date.now() - 5000);
expect(variable.created).toBeLessThanOrEqual(Date.now());
expect(variable.updated).toBe(variable.created);
// Verify persistence
const retrieved = varStore.get(variable.id);
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe(variable.id);
expect(retrieved?.scope).toBe(variable.scope);
expect(retrieved?.value).toBe(variable.value);
});
test("1.2: Create variable fails with scope not ending in /", () => {
expect(() => varStore.create("uwf/thread", hashA)).toThrow(
InvalidScopeError,
);
expect(() => varStore.create("uwf/thread", hashA)).toThrow(
"scope must end with /",
);
});
test("1.3: Create variable fails with non-existent CAS node", () => {
const fakeHash = "FAKEHASH00000";
expect(() => varStore.create("uwf/", fakeHash)).toThrow(
CasNodeNotFoundError,
);
expect(() => varStore.create("uwf/", fakeHash)).toThrow(
`CAS node not found: ${fakeHash}`,
);
});
});
describe("Test Group 2: Variable Retrieval", () => {
test("2.1: Get existing variable", () => {
const created = varStore.create("uwf/thread/", hashA);
const retrieved = varStore.get(created.id);
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe(created.id);
expect(retrieved?.scope).toBe("uwf/thread/");
expect(retrieved?.value).toBe(hashA);
expect(retrieved?.schema).toBe(schemaA);
expect(retrieved?.created).toBe(created.created);
expect(retrieved?.updated).toBe(created.updated);
});
test("2.2: Get non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
const result = varStore.get(fakeId);
expect(result).toBeNull();
});
});
describe("Test Group 3: Variable Update (Schema Consistent)", () => {
test("3.1: Update variable with matching schema", async () => {
const created = varStore.create("uwf/thread/", hashA);
const t1 = created.created;
// Wait a bit to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = varStore.update(created.id, hashB);
expect(updated.id).toBe(created.id);
expect(updated.scope).toBe("uwf/thread/");
expect(updated.value).toBe(hashB);
expect(updated.schema).toBe(schemaA);
expect(updated.created).toBe(t1);
expect(updated.updated).toBeGreaterThan(t1);
expect(updated.updated).toBeGreaterThan(Date.now() - 5000);
expect(updated.updated).toBeLessThanOrEqual(Date.now());
// Verify persistence
const retrieved = varStore.get(created.id);
expect(retrieved?.value).toBe(hashB);
expect(retrieved?.updated).toBe(updated.updated);
});
test("3.2: Update variable to same value is idempotent", () => {
const created = varStore.create("uwf/thread/", hashA);
const updated = varStore.update(created.id, hashA);
expect(updated.value).toBe(hashA);
expect(updated.schema).toBe(schemaA);
// Updated timestamp may change, this is implementation-defined
});
});
describe("Test Group 4: Variable Update (Schema Mismatch)", () => {
test("4.1: Update variable fails with schema mismatch", () => {
const created = varStore.create("uwf/thread/", hashA);
expect(() => varStore.update(created.id, hashC)).toThrow(
SchemaMismatchError,
);
const error = (() => {
try {
varStore.update(created.id, hashC);
return null;
} catch (e) {
return e as SchemaMismatchError;
}
})();
expect(error).not.toBeNull();
expect(error?.expected).toBe(schemaA);
expect(error?.actual).toBe(schemaB);
expect(error?.message.toLowerCase()).toContain("schema mismatch");
// Verify variable is unchanged
const retrieved = varStore.get(created.id);
expect(retrieved?.value).toBe(hashA);
});
test("4.2: Update variable fails with non-existent CAS node", () => {
const created = varStore.create("uwf/thread/", hashA);
const fakeHash = "FAKEHASH00000";
expect(() => varStore.update(created.id, fakeHash)).toThrow(
CasNodeNotFoundError,
);
});
test("4.3: Update non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
expect(() => varStore.update(fakeId, hashA)).toThrow(
VariableNotFoundError,
);
expect(() => varStore.update(fakeId, hashA)).toThrow(
`Variable not found: ${fakeId}`,
);
});
});
describe("Test Group 5: Variable Deletion", () => {
test("5.1: Delete existing variable", () => {
const created = varStore.create("uwf/thread/", hashA);
const deleted = varStore.delete(created.id);
expect(deleted.id).toBe(created.id);
expect(deleted.scope).toBe(created.scope);
expect(deleted.value).toBe(created.value);
expect(deleted.schema).toBe(created.schema);
// Verify it's removed from database
const retrieved = varStore.get(created.id);
expect(retrieved).toBeNull();
});
test("5.2: Get deleted variable", () => {
const created = varStore.create("uwf/thread/", hashA);
varStore.delete(created.id);
const retrieved = varStore.get(created.id);
expect(retrieved).toBeNull();
});
test("5.3: Delete non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
expect(() => varStore.delete(fakeId)).toThrow(VariableNotFoundError);
expect(() => varStore.delete(fakeId)).toThrow(
`Variable not found: ${fakeId}`,
);
});
});
describe("Test Group 6: Variable Listing", () => {
test("6.1: list() returns all variables with matching scope prefix", async () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("uwf/thread/", hashB);
const var3 = varStore.create("uwf/agent/", hashA);
varStore.create("app/config/", hashA);
// Wait a bit to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
const results = varStore.list({ scope: "uwf/" });
expect(results).toHaveLength(3);
expect(results.every((v) => v.scope.startsWith("uwf/"))).toBe(true);
// Verify ordering by created timestamp
expect(results[0]?.id).toBe(var1.id);
expect(results[1]?.id).toBe(var2.id);
expect(results[2]?.id).toBe(var3.id);
});
test("6.2: list() returns empty array when no matches", () => {
varStore.create("uwf/thread/", hashA);
const results = varStore.list({ scope: "nonexistent/" });
expect(results).toHaveLength(0);
expect(Array.isArray(results)).toBe(true);
});
test("6.3: list() returns all variables when scope is empty string", () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("app/config/", hashB);
const var3 = varStore.create("test/", hashC);
const results = varStore.list({ scope: "" });
expect(results).toHaveLength(3);
expect(results.map((v) => v.id)).toContain(var1.id);
expect(results.map((v) => v.id)).toContain(var2.id);
expect(results.map((v) => v.id)).toContain(var3.id);
});
test("6.4: list() validates scope format (must end with /)", () => {
varStore.create("uwf/thread/", hashA);
expect(() => varStore.list({ scope: "uwf" })).toThrow(InvalidScopeError);
expect(() => varStore.list({ scope: "uwf" })).toThrow(
"scope must end with /",
);
});
test("6.5: list() returns exact scope match and sub-scopes", () => {
varStore.create("uwf/thread/", hashA);
varStore.create("uwf/thread/active/", hashB);
const results = varStore.list({ scope: "uwf/thread/" });
expect(results).toHaveLength(2);
expect(results[0]?.scope).toBe("uwf/thread/");
expect(results[1]?.scope).toBe("uwf/thread/active/");
});
test("6.6: list() result ordering is deterministic", async () => {
// Create 5 variables with the same scope prefix
const var1 = varStore.create("test/", hashA);
await new Promise((resolve) => setTimeout(resolve, 2));
const var2 = varStore.create("test/", hashB);
await new Promise((resolve) => setTimeout(resolve, 2));
const var3 = varStore.create("test/", hashA);
await new Promise((resolve) => setTimeout(resolve, 2));
const var4 = varStore.create("test/", hashB);
await new Promise((resolve) => setTimeout(resolve, 2));
const var5 = varStore.create("test/", hashA);
// Call list multiple times
const results1 = varStore.list({ scope: "test/" });
const results2 = varStore.list({ scope: "test/" });
const results3 = varStore.list({ scope: "test/" });
// All results should be identical
expect(results1.map((v) => v.id)).toEqual(results2.map((v) => v.id));
expect(results2.map((v) => v.id)).toEqual(results3.map((v) => v.id));
// Verify ordering by created timestamp (oldest first)
expect(results1[0]?.id).toBe(var1.id);
expect(results1[1]?.id).toBe(var2.id);
expect(results1[2]?.id).toBe(var3.id);
expect(results1[3]?.id).toBe(var4.id);
expect(results1[4]?.id).toBe(var5.id);
});
});
describe("Test Group 7: Integration Tests", () => {
test("7.1: Full lifecycle workflow", async () => {
// Create variable
const var1 = varStore.create("uwf/thread/", hashA);
expect(var1.value).toBe(hashA);
// Get variable
const retrieved1 = varStore.get(var1.id);
expect(retrieved1?.value).toBe(hashA);
// Wait to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
// Update variable
const updated = varStore.update(var1.id, hashB);
expect(updated.value).toBe(hashB);
expect(updated.updated).toBeGreaterThan(var1.created);
// Get updated variable
const retrieved2 = varStore.get(var1.id);
expect(retrieved2?.value).toBe(hashB);
// Delete variable
const deleted = varStore.delete(var1.id);
expect(deleted.value).toBe(hashB);
// Verify deletion
const retrieved3 = varStore.get(var1.id);
expect(retrieved3).toBeNull();
});
test("7.2: Multiple variables with same scope", () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("uwf/thread/", hashB);
// Verify independence
expect(var1.id).not.toBe(var2.id);
const retrieved1 = varStore.get(var1.id);
const retrieved2 = varStore.get(var2.id);
expect(retrieved1?.value).toBe(hashA);
expect(retrieved2?.value).toBe(hashB);
// Update var1, verify var2 is unaffected
varStore.update(var1.id, hashB);
const retrieved2After = varStore.get(var2.id);
expect(retrieved2After?.value).toBe(hashB);
expect(retrieved2After?.updated).toBe(var2.updated);
// Delete var1, verify var2 still exists
varStore.delete(var1.id);
const retrieved2Final = varStore.get(var2.id);
expect(retrieved2Final).not.toBeNull();
expect(retrieved2Final?.value).toBe(hashB);
});
test("7.3: Variables with hierarchical scopes", () => {
const var1 = varStore.create("uwf/", hashA);
const var2 = varStore.create("uwf/thread/", hashA);
const var3 = varStore.create("uwf/workflow/", hashA);
expect(var1.scope).toBe("uwf/");
expect(var2.scope).toBe("uwf/thread/");
expect(var3.scope).toBe("uwf/workflow/");
// All should exist independently
expect(varStore.get(var1.id)).not.toBeNull();
expect(varStore.get(var2.id)).not.toBeNull();
expect(varStore.get(var3.id)).not.toBeNull();
});
});
});
+256
View File
@@ -0,0 +1,256 @@
import { Database } from "bun:sqlite";
import { ulid } from "ulidx";
import type { Store } from "./types.js";
import type { Variable, VariableId } from "./variable.js";
/**
* Custom error types for variable operations
*/
export class VariableNotFoundError extends Error {
constructor(id: VariableId) {
super(`Variable not found: ${id}`);
this.name = "VariableNotFoundError";
}
}
export class SchemaMismatchError extends Error {
constructor(
public expected: string,
public actual: string,
) {
super(`Schema mismatch: expected ${expected}, got ${actual}`);
this.name = "SchemaMismatchError";
}
}
export class InvalidScopeError extends Error {
constructor(scope: string) {
super(`Invalid scope: scope must end with / (got: ${scope})`);
this.name = "InvalidScopeError";
}
}
export class CasNodeNotFoundError extends Error {
constructor(hash: string) {
super(`CAS node not found: ${hash}`);
this.name = "CasNodeNotFoundError";
}
}
/**
* Variable store with SQLite backend
*/
export class VariableStore {
private db: Database;
constructor(
dbPath: string,
private casStore: Store,
) {
this.db = new Database(dbPath, { create: true });
this.initDb();
}
private initDb(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS variables (
id TEXT PRIMARY KEY,
scope TEXT NOT NULL,
value TEXT NOT NULL,
schema TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_var_scope ON variables(scope);
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
`);
}
/**
* Validate that scope ends with /
*/
private validateScope(scope: string): void {
if (!scope.endsWith("/")) {
throw new InvalidScopeError(scope);
}
}
/**
* Extract schema hash from CAS node
*/
private extractSchema(hash: string): string {
const node = this.casStore.get(hash);
if (node === null) {
throw new CasNodeNotFoundError(hash);
}
return node.type;
}
/**
* Create a new variable
*/
create(scope: string, value: string): Variable {
this.validateScope(scope);
const schema = this.extractSchema(value);
const id = ulid();
const now = Date.now();
const stmt = this.db.prepare(`
INSERT INTO variables (id, scope, value, schema, created, updated)
VALUES (?, ?, ?, ?, ?, ?)
`);
stmt.run(id, scope, value, schema, now, now);
return {
id,
scope,
value,
schema,
created: now,
updated: now,
};
}
/**
* Get a variable by ID
*/
get(id: VariableId): Variable | null {
const stmt = this.db.prepare(`
SELECT id, scope, value, schema, created, updated
FROM variables
WHERE id = ?
`);
const row = stmt.get(id) as
| {
id: string;
scope: string;
value: string;
schema: string;
created: number;
updated: number;
}
| undefined
| null;
if (row === undefined || row === null) {
return null;
}
return {
id: row.id,
scope: row.scope,
value: row.value,
schema: row.schema,
created: row.created,
updated: row.updated,
};
}
/**
* Update a variable's value (with schema validation)
*/
update(id: VariableId, value: string): Variable {
const existing = this.get(id);
if (existing === null) {
throw new VariableNotFoundError(id);
}
const newSchema = this.extractSchema(value);
if (newSchema !== existing.schema) {
throw new SchemaMismatchError(existing.schema, newSchema);
}
const now = Date.now();
const stmt = this.db.prepare(`
UPDATE variables
SET value = ?, updated = ?
WHERE id = ?
`);
stmt.run(value, now, id);
return {
...existing,
value,
updated: now,
};
}
/**
* Delete a variable
*/
delete(id: VariableId): Variable {
const existing = this.get(id);
if (existing === null) {
throw new VariableNotFoundError(id);
}
const stmt = this.db.prepare(`
DELETE FROM variables WHERE id = ?
`);
stmt.run(id);
return existing;
}
/**
* List variables matching a scope prefix
*/
list(options?: { scope?: string }): Variable[] {
const scope = options?.scope ?? "";
// Validate scope format (must end with / if non-empty)
if (scope !== "" && !scope.endsWith("/")) {
throw new InvalidScopeError(scope);
}
const stmt = this.db.prepare(`
SELECT id, scope, value, schema, created, updated
FROM variables
WHERE scope LIKE ? || '%'
ORDER BY created ASC
`);
const rows = stmt.all(scope) as Array<{
id: string;
scope: string;
value: string;
schema: string;
created: number;
updated: number;
}>;
return rows.map((row) => ({
id: row.id,
scope: row.scope,
value: row.value,
schema: row.schema,
created: row.created,
updated: row.updated,
}));
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
}
/**
* Create a variable store
*/
export function createVariableStore(
dbPath: string,
casStore: Store,
): VariableStore {
return new VariableStore(dbPath, casStore);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Hash } from "./types.js";
/**
* ULID identifier (26-character Crockford Base32)
*/
export type VariableId = string;
/**
* Variable: mutable binding to an immutable CAS node
*/
export type Variable = {
id: VariableId;
scope: string; // hierarchical path, must end with /
value: Hash; // CAS node hash
schema: Hash; // extracted from value's CAS node.type
created: number; // epoch ms
updated: number; // epoch ms
};
@@ -69,7 +69,6 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
expect(properties).not.toHaveProperty("$id");
expect(properties).not.toHaveProperty("$defs");
expect(properties).not.toHaveProperty("allOf");
expect(properties).not.toHaveProperty("oneOf");
expect(properties).not.toHaveProperty("not");
});
@@ -148,6 +147,18 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
expect(hash).toBeTruthy();
});
test("2.7b: Accept schema with oneOf", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
oneOf: [
{ properties: { status: { const: "ready" } }, required: ["status"] },
{ properties: { status: { const: "failed" } }, required: ["status"] },
],
});
expect(hash).toBeTruthy();
});
test("2.8: Accept schema with array items", async () => {
const store = new MemStore();
await bootstrap(store);
+1 -4
View File
@@ -12,10 +12,7 @@
"skipLibCheck": true,
"paths": {
"@uncaged/json-cas": ["./packages/json-cas/src/index.ts"],
"@uncaged/json-cas-fs": ["./packages/json-cas-fs/src/index.ts"],
"@uncaged/json-cas-workflow": [
"./packages/json-cas-workflow/src/index.ts"
]
"@uncaged/json-cas-fs": ["./packages/json-cas-fs/src/index.ts"]
},
"composite": true,
"declaration": true,