Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c20c6df2bf | |||
| b2ee62dce2 | |||
| 1dacd699d5 | |||
| 0e38fd3ea9 | |||
| e00a23dd80 | |||
| d2225c8cdf | |||
| 4d7b439aaa | |||
| ccca0e60d1 | |||
| b7aa90d8e6 | |||
| 9a1954f6f9 | |||
| b062fcbc44 | |||
| 0706307e85 | |||
| d57a454b78 | |||
| 34847cae59 |
@@ -0,0 +1,3 @@
|
||||
# Sync README
|
||||
|
||||
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.d.ts.map
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
+108
-134
@@ -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." }
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# CLAUDE.md — json-cas
|
||||
|
||||
Self-describing content-addressable storage with JSON Schema typed nodes.
|
||||
|
||||
## Project Structure
|
||||
|
||||
Monorepo with 4 packages under `packages/`:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `json-cas` | Core CAS engine — hashing, schema, store, verify, bootstrap |
|
||||
| `json-cas-fs` | Filesystem-backed CAS store |
|
||||
| `cli-json-cas` | CLI tool |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Bun
|
||||
- **Language:** TypeScript (strict mode, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
|
||||
- **Build:** `tsc --build` (composite project references)
|
||||
- **Test:** `bun test`
|
||||
- **Lint/Format:** Biome (`biome check .` / `biome format --write .`)
|
||||
- **Publish:** Changesets → npmjs (`@uncaged/*`)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun test # Run all tests
|
||||
bun run build # Build all packages
|
||||
bun run check # Biome lint
|
||||
bun run format # Biome format (auto-fix)
|
||||
```
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Strict mode** — no `any`, no unchecked index access, no implicit overrides
|
||||
- **`verbatimModuleSyntax`** — use `import type` for type-only imports
|
||||
- **Import paths** — use `.js` extension in imports (ESM convention with bundler resolution)
|
||||
- **Export style** — named exports only, re-export from `index.ts`
|
||||
|
||||
### Biome Rules
|
||||
|
||||
- `noConsole: "error"` globally (except `cli-json-cas`)
|
||||
- Recommended ruleset enabled
|
||||
- Auto-organize imports via `assist.actions.source.organizeImports`
|
||||
- Indent: 2 spaces
|
||||
|
||||
### Naming
|
||||
|
||||
- Types: `PascalCase` (`CasNode`, `Hash`, `Store`)
|
||||
- Functions: `camelCase` (`computeHash`, `createMemoryStore`)
|
||||
- Constants: `UPPER_SNAKE_CASE` (`BOOTSTRAP_STORE`)
|
||||
- Files: `kebab-case.ts`
|
||||
- Test files: co-located as `*.test.ts`
|
||||
|
||||
### Key Types
|
||||
|
||||
- `Hash` — 13-character uppercase Crockford Base32 string (XXH64)
|
||||
- `CasNode` — content-addressed node with schema
|
||||
- `Store` — abstract storage interface (get/put)
|
||||
|
||||
## Git
|
||||
|
||||
- Commit format: `type: description` (conventional commits)
|
||||
- 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
|
||||
2. `bun run check` — no lint errors
|
||||
3. `bun run build` — builds cleanly
|
||||
@@ -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.
|
||||
|
||||
@@ -14,18 +14,18 @@
|
||||
},
|
||||
"packages/cli-json-cas": {
|
||||
"name": "@uncaged/cli-json-cas",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"bin": {
|
||||
"json-cas": "./src/index.ts",
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0",
|
||||
"@uncaged/json-cas": "workspace:^",
|
||||
"@uncaged/json-cas-fs": "workspace:^",
|
||||
},
|
||||
},
|
||||
"packages/json-cas": {
|
||||
"name": "@uncaged/json-cas",
|
||||
"version": "1.0.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
@@ -34,17 +34,17 @@
|
||||
},
|
||||
"packages/json-cas-fs": {
|
||||
"name": "@uncaged/json-cas-fs",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
"@uncaged/json-cas": "workspace:^",
|
||||
"cborg": "^4.2.3",
|
||||
},
|
||||
},
|
||||
"packages/json-cas-workflow": {
|
||||
"name": "@uncaged/json-cas-workflow",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
"@uncaged/json-cas": "workspace:^",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -311,14 +311,6 @@
|
||||
|
||||
"@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"@uncaged/cli-json-cas/@uncaged/json-cas": ["@uncaged/json-cas@0.3.0", "", { "dependencies": { "ajv": "^8.20.0", "cborg": "^4.2.3", "xxhash-wasm": "^1.1.0" } }, "sha512-LR8Uow7cBdvH+6y9mh9Fd7zDs8fWhfhpVZVsexfdK1KKnGaR7WvukuhBj6r0FbOZ78j7jhjeEfzsUXR2cHELwQ=="],
|
||||
|
||||
"@uncaged/cli-json-cas/@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@0.3.0", "", { "dependencies": { "@uncaged/json-cas": "^0.3.0", "cborg": "^4.2.3" } }, "sha512-shelE7PXtBAsJtJ2Axo5yBScErV/kgi2OUiIUXnEP8BL6L760BRz9W6PDb6jHVKrWOh1HIdYUYODYaHRWY0UxA=="],
|
||||
|
||||
"@uncaged/json-cas-fs/@uncaged/json-cas": ["@uncaged/json-cas@0.4.0", "", { "dependencies": { "ajv": "^8.20.0", "cborg": "^4.2.3", "xxhash-wasm": "^1.1.0" } }, "sha512-DQ65BiMwPeitxEmMYEyQoVO99GQeOBMv0Lgc/ZZkUCKFpTkxZ0tngDD1NsF7suLkIOLxnuBgUKon7t7Yc8eWgw=="],
|
||||
|
||||
"@uncaged/json-cas-workflow/@uncaged/json-cas": ["@uncaged/json-cas@0.4.0", "", { "dependencies": { "ajv": "^8.20.0", "cborg": "^4.2.3", "xxhash-wasm": "^1.1.0" } }, "sha512-DQ65BiMwPeitxEmMYEyQoVO99GQeOBMv0Lgc/ZZkUCKFpTkxZ0tngDD1NsF7suLkIOLxnuBgUKon7t7Yc8eWgw=="],
|
||||
|
||||
"read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
@@ -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
|
||||
+3
-2
@@ -12,9 +12,10 @@
|
||||
"typescript": "^5.8.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "@uncaged/cli-json-cas",
|
||||
"version": "0.3.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": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0"
|
||||
"@uncaged/json-cas": "^0.5.3",
|
||||
"@uncaged/json-cas-fs": "^0.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import {
|
||||
bootstrap,
|
||||
@@ -53,7 +53,8 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
const { flags, positional } = parseArgs(process.argv.slice(2));
|
||||
|
||||
const defaultStorePath = join(homedir(), ".uncaged", "json-cas");
|
||||
const storePath = typeof flags.store === "string" ? flags.store : defaultStorePath;
|
||||
const storePath =
|
||||
typeof flags.store === "string" ? flags.store : defaultStorePath;
|
||||
const compact = flags.json === true;
|
||||
|
||||
// ---- Helpers ----
|
||||
@@ -176,8 +177,6 @@ async function cmdVerify(args: string[]): Promise<void> {
|
||||
console.log(ok ? "ok" : "corrupted");
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function cmdRefs(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas refs <hash>");
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/json-cas-fs",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.3",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -10,12 +10,16 @@
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist", "src"],
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
"@uncaged/json-cas": "^0.5.3",
|
||||
"cborg": "^4.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
BootstrapCapableStore,
|
||||
CasNode,
|
||||
Hash,
|
||||
} from "@uncaged/json-cas";
|
||||
import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
|
||||
|
||||
import {
|
||||
BOOTSTRAP_STORE,
|
||||
@@ -145,7 +141,11 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
|
||||
if (!data.has(hash)) {
|
||||
const node: CasNode = { type: typeHash, payload, timestamp: Date.now() };
|
||||
const node: CasNode = {
|
||||
type: typeHash,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
data.set(hash, node);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -6,7 +6,5 @@
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"references": [
|
||||
{ "path": "../json-cas" }
|
||||
]
|
||||
"references": [{ "path": "../json-cas" }]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/json-cas-workflow",
|
||||
"version": "0.4.1",
|
||||
"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": "^0.4.0"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"references": [
|
||||
{ "path": "../json-cas" }
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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/`.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/json-cas",
|
||||
"version": "1.0.0",
|
||||
"version": "0.5.3",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -10,9 +10,13 @@
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist", "src"],
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
|
||||
@@ -41,12 +41,19 @@ const BOOTSTRAP_PAYLOAD = {
|
||||
items: { type: "string" },
|
||||
},
|
||||
additionalProperties: {
|
||||
anyOf: [{ type: "boolean" }, { type: "object", additionalProperties: false }],
|
||||
anyOf: [
|
||||
{ type: "boolean" },
|
||||
{ type: "object", additionalProperties: false },
|
||||
],
|
||||
},
|
||||
anyOf: {
|
||||
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" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { bootstrap } from "./bootstrap.js";
|
||||
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
export type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
export { cborEncode } from "./cbor.js";
|
||||
export { computeHash, computeSelfHash } from "./hash.js";
|
||||
export type { JSONSchema } from "./schema.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { CasNode, Hash } from "./types.js";
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import * as AjvModule from "ajv";
|
||||
// but tsc with verbatimModuleSyntax sees the namespace wrapper.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: CJS interop
|
||||
const Ajv = ((AjvModule as any).default ?? AjvModule) as {
|
||||
new (): { addFormat(name: string, re: RegExp): void; validate(schema: unknown, data: unknown): boolean };
|
||||
new (): {
|
||||
addFormat(name: string, re: RegExp): void;
|
||||
validate(schema: unknown, data: unknown): boolean;
|
||||
};
|
||||
};
|
||||
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
@@ -14,10 +17,6 @@ export type JSONSchema = Record<string, unknown>;
|
||||
|
||||
export class SchemaValidationError extends Error {
|
||||
override readonly name = "SchemaValidationError";
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const ajv = new Ajv();
|
||||
@@ -29,6 +28,7 @@ const ALLOWED_SCHEMA_KEYS = new Set([
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"items",
|
||||
"format",
|
||||
"title",
|
||||
@@ -109,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;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "../src/bootstrap.js";
|
||||
import {
|
||||
putSchema,
|
||||
getSchema,
|
||||
validate,
|
||||
refs,
|
||||
walk,
|
||||
SchemaValidationError,
|
||||
} from "../src/schema.js";
|
||||
import { MemStore } from "../src/mem-store.js";
|
||||
import type { JSONSchema } from "../src/schema.js";
|
||||
import {
|
||||
getSchema,
|
||||
putSchema,
|
||||
refs,
|
||||
SchemaValidationError,
|
||||
validate,
|
||||
walk,
|
||||
} from "../src/schema.js";
|
||||
import type { CasNode } from "../src/types.js";
|
||||
|
||||
describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
@@ -37,7 +38,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
const properties = (metaSchema?.properties as Record<string, unknown>) || {};
|
||||
const properties =
|
||||
(metaSchema?.properties as Record<string, unknown>) || {};
|
||||
|
||||
// Check that all supported keywords are defined
|
||||
expect(properties).toHaveProperty("type");
|
||||
@@ -59,14 +61,14 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
const properties = (metaSchema?.properties as Record<string, unknown>) || {};
|
||||
const properties =
|
||||
(metaSchema?.properties as Record<string, unknown>) || {};
|
||||
|
||||
// Unsupported keywords should not be in properties
|
||||
expect(properties).not.toHaveProperty("$ref");
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -145,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);
|
||||
@@ -225,7 +239,10 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
test("3.2: Reject schema with type as number", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(async () => await putSchema(store, { type: 123 } as any)).toThrow();
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: 123 } as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.3: Reject schema with properties not an object", async () => {
|
||||
@@ -236,7 +253,7 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
properties: "not-an-object",
|
||||
} as any)
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -248,7 +265,7 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
required: "name",
|
||||
} as any)
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -260,7 +277,7 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name", 123, true],
|
||||
} as any)
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -272,7 +289,7 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: "yes",
|
||||
} as any)
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -281,7 +298,9 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { anyOf: { type: "string" } } as any)
|
||||
await putSchema(store, {
|
||||
anyOf: { type: "string" },
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -296,7 +315,10 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: "array", items: "string" } as any)
|
||||
await putSchema(store, {
|
||||
type: "array",
|
||||
items: "string",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -305,7 +327,10 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: "string", format: 123 } as any)
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
format: 123,
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -314,7 +339,10 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: "string", enum: "red" } as any)
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
enum: "red",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -322,7 +350,7 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () => await putSchema(store, { type: "string", enum: [] })
|
||||
async () => await putSchema(store, { type: "string", enum: [] }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -331,7 +359,10 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: "string", title: 123 } as any)
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
title: 123,
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -343,7 +374,7 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
description: ["not a string"],
|
||||
} as any)
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -352,7 +383,9 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { $ref: "#/definitions/user" } as any)
|
||||
await putSchema(store, {
|
||||
$ref: "#/definitions/user",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -360,7 +393,8 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () => await putSchema(store, "not-a-schema" as any)
|
||||
async () =>
|
||||
await putSchema(store, "not-a-schema" as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -374,7 +408,7 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
properties: {
|
||||
name: { type: "invalid-type" },
|
||||
},
|
||||
} as any)
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -384,7 +418,7 @@ describe("Test Suite 4: Error Messages and Debugging", () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
try {
|
||||
await putSchema(store, { type: 123 } as any);
|
||||
await putSchema(store, { type: 123 } as unknown as JSONSchema);
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SchemaValidationError);
|
||||
@@ -396,7 +430,7 @@ describe("Test Suite 4: Error Messages and Debugging", () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
try {
|
||||
await putSchema(store, { type: "invalid-type" } as any);
|
||||
await putSchema(store, { type: "invalid-type" } as unknown as JSONSchema);
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SchemaValidationError);
|
||||
@@ -444,9 +478,7 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const dataNode = store.get(
|
||||
await store.put(schemaHash, { name: "test" })
|
||||
);
|
||||
const dataNode = store.get(await store.put(schemaHash, { name: "test" }));
|
||||
|
||||
expect(dataNode).not.toBeNull();
|
||||
expect(validate(store, dataNode as CasNode)).toBe(true);
|
||||
@@ -465,7 +497,7 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
});
|
||||
|
||||
const dataNode = store.get(
|
||||
await store.put(schemaHash, { name: 123 }) // wrong type
|
||||
await store.put(schemaHash, { name: 123 }), // wrong type
|
||||
);
|
||||
|
||||
expect(dataNode).not.toBeNull();
|
||||
@@ -509,9 +541,7 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
});
|
||||
|
||||
const refHash = "0000000000001";
|
||||
const dataNode = store.get(
|
||||
await store.put(schemaHash, { ref: refHash })
|
||||
);
|
||||
const dataNode = store.get(await store.put(schemaHash, { ref: refHash }));
|
||||
|
||||
const extractedRefs = refs(store, dataNode as CasNode);
|
||||
expect(extractedRefs).toContain(refHash);
|
||||
@@ -525,10 +555,7 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
type: "object",
|
||||
properties: {
|
||||
next: {
|
||||
anyOf: [
|
||||
{ type: "string", format: "cas_ref" },
|
||||
{ type: "null" },
|
||||
],
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -563,7 +590,8 @@ describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
// The meta-schema should have properties that can contain schemas
|
||||
const properties = (metaSchema?.properties as Record<string, unknown>) || {};
|
||||
const properties =
|
||||
(metaSchema?.properties as Record<string, unknown>) || {};
|
||||
expect(properties).toHaveProperty("properties");
|
||||
});
|
||||
|
||||
@@ -576,7 +604,7 @@ describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
unknownKeyword: "value",
|
||||
} as any);
|
||||
} as unknown as JSONSchema);
|
||||
// If we get here, meta-schema allows additional properties
|
||||
// This is acceptable behavior
|
||||
} catch (error) {
|
||||
@@ -594,12 +622,15 @@ describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||
expect(hash1).toBeTruthy();
|
||||
|
||||
// Array of types
|
||||
const hash2 = await putSchema(store, { type: ["string", "null"] } as any);
|
||||
const hash2 = await putSchema(store, {
|
||||
type: ["string", "null"],
|
||||
} as unknown as JSONSchema);
|
||||
expect(hash2).toBeTruthy();
|
||||
|
||||
// Invalid type (number)
|
||||
expect(
|
||||
async () => await putSchema(store, { type: 123 } as any)
|
||||
async () =>
|
||||
await putSchema(store, { type: 123 } as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -10,6 +10,10 @@
|
||||
"noImplicitOverride": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@uncaged/json-cas": ["./packages/json-cas/src/index.ts"],
|
||||
"@uncaged/json-cas-fs": ["./packages/json-cas-fs/src/index.ts"]
|
||||
},
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
Reference in New Issue
Block a user