Compare commits

...

38 Commits

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

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

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

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

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

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

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

Fixes #21

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

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

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

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

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

小橘 🍊(NEKO Team)
2026-05-25 09:21:29 +00:00
xiaomo b7aa90d8e6 chore: add *.tsbuildinfo to .gitignore, remove duplicate dist/ entry 2026-05-25 04:14:14 +00:00
xiaomo 9a1954f6f9 fix: resolve lint errors — remove useless constructor, fix import ordering, replace as any with as unknown as JSONSchema in tests, apply biome formatting 2026-05-25 04:09:47 +00:00
xiaomo b062fcbc44 fix: add tsconfig paths for workspace package resolution
bun workspace:^ deps don't create node_modules symlinks,
so tsc can't resolve @uncaged/* packages by name.
Add explicit paths mapping to fix tsc --build.
2026-05-25 04:08:14 +00:00
xiaomo 0706307e85 fix: align all packages to 0.5.0 and restore workspace:^ deps
- All @uncaged/* packages → 0.5.0 (fixed versioning per changesets config)
- Restore workspace:^ for internal deps (was broken to ^0.3.0/^0.4.0)
- Regenerate bun.lock (removes duplicate npm registry entries)
2026-05-25 04:05:17 +00:00
xiaoju d57a454b78 docs: add CLAUDE.md with project conventions and code standards 2026-05-25 04:03:08 +00:00
xiaomo 34847cae59 Merge pull request 'feat!: self-validating meta-schema for putSchema' (#16) from fix/15-self-validating-meta-schema into main 2026-05-25 03:53:11 +00:00
xiaoju 054d78296a feat!: self-validating meta-schema for putSchema
Replace bootstrap payload with a JSON Schema meta-schema describing
our supported schema subset. putSchema now validates input schemas
against the meta-schema before storing, rejecting invalid schemas
with SchemaValidationError.

- bootstrap.ts: self-describing meta-schema (type, properties, required,
  additionalProperties, anyOf, items, format, title, enum, const, description)
- schema.ts: recursive isValidSchema(), SchemaValidationError class
- index.ts: export SchemaValidationError
- package.json: bump 0.4.0 → 1.0.0 (breaking change)

BREAKING CHANGE: meta-schema hash changed, old CAS data invalid.

Fixes #15
2026-05-25 03:51:43 +00:00
xiaoju 8f54dcfa7c chore: remove hardcoded paths from solve-issue workflow
Use git rev-parse, git worktree list, and git remote to detect
paths dynamically. No more ~/repos/ assumptions.

— 小橘 🍊(NEKO Team)
2026-05-25 01:43:40 +00:00
xiaoju 7828dd1c41 chore: generalize solve-issue workflow for any repo
Remove hardcoded ~/repos/workflow paths. Use git rev-parse and
basename to detect repo root and worktree base dynamically.

— 小橘 🍊(NEKO Team)
2026-05-25 01:40:48 +00:00
xiaoju a30af6efb5 chore: use workflow project's solve-issue with tester/committer
Replace minimal version with full 5-role workflow from workflow project.

— 小橘 🍊(NEKO Team)
2026-05-25 01:36:50 +00:00
xiaoju 712f930072 chore: add solve-issue workflow definition
— 小橘 🍊(NEKO Team)
2026-05-25 01:35:27 +00:00
scottwei cfe791180b Merge pull request 'feat: remove Store.list() from interface' (#14) from issue-11-remove-store-list into main
Reviewed-on: #14
2026-05-25 01:35:02 +00:00
xiaoju 09526b63da fix: json-cas-fs and json-cas-workflow depend on json-cas ^0.4.0
小橘 🍊(NEKO Team)
2026-05-19 10:21:41 +00:00
xiaoju 00f191e105 chore: bump to 0.4.0
小橘 🍊(NEKO Team)
2026-05-19 10:19:38 +00:00
xiaoju 52cb7a30ba fix: publish compiled .js + .d.ts instead of raw .ts sources
- Add tsc --build pipeline for json-cas, json-cas-fs, json-cas-workflow
- Update package.json exports to point to dist/ (types + import)
- Fix Store type error: use BootstrapCapableStore for stores with bootstrap
- Export BootstrapCapableStore type from json-cas
- Fix meta-schema: nodeSchema now uses real JSON Schema (draft 2020-12)
- Exclude test files from tsc compilation

Breaking: bootstrap hash changes due to meta-schema payload update.

小橘 🍊(NEKO Team)
2026-05-19 10:18:31 +00:00
Scott Wei 1b53cf5ff8 feat: remove Store.list() from interface
Removes the list() method from the Store type and all implementations.
Callers now use listByType() or has() instead.

The CLI 'list' subcommand is removed. 'schema list' now uses
listByType(metaHash) to enumerate schemas.

Closes #11
2026-05-19 01:24:12 +08:00
xiaoju 17ed619900 chore: release @uncaged/* 0.3.0 2026-05-18 15:00:40 +00:00
xiaomo bad62a82a9 Merge pull request 'feat: disallow self-referencing nodes except via bootstrap()' (#13) from feat/12-no-null-type into main 2026-05-18 14:59:17 +00:00
xiaoju 4989cd31ba Remove null from Store.put typeHash parameter
Self-referencing nodes are created only through bootstrap() via an internal BOOTSTRAP_STORE symbol on memory and fs store implementations. put() always requires a Hash typeHash and uses computeHash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:40 +00:00
xiaoju 38aad696fc chore: release @uncaged/* 0.2.0 2026-05-18 14:15:33 +00:00
xiaomo fb81f5a429 Merge pull request 'feat: add listByType(typeHash) to Store interface' (#10) from feat/9-list-by-type into main 2026-05-18 14:13:16 +00:00
xiaoju 5fc475704b feat: add listByType(typeHash) to Store interface
Implement in-memory type index and fs append-only _index files. Rebuild index from existing .bin nodes on first load when _index is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:11:53 +00:00
xiaoju 6e77e4a110 fix: replace workspace:^ deps with real versions + merge cli global store path
- All packages bumped to 0.1.3 via changesets (fixed group)
- Replace workspace:^ with ^0.1.x in json-cas-fs, json-cas-workflow, cli-json-cas
- Merge PR #8: cli default store path → ~/.uncaged/json-cas/

Co-authored-by: 星月 🌙 (SORA Team)
小橘 🍊(NEKO Team)
2026-05-18 10:47:12 +00:00
xiaoju a3a21b153c fix: replace workspace:^ with ^0.1.1 in json-cas-fs deps 2026-05-18 10:43:22 +00:00
44 changed files with 3972 additions and 1148 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/changelog-github",
"changelog": ["@changesets/changelog-github", { "repo": "uncaged/json-cas" }],
"commit": false,
"fixed": [["@uncaged/*"]],
"linked": [],
+3
View File
@@ -0,0 +1,3 @@
# Sync README
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
+1
View File
@@ -1,3 +1,4 @@
node_modules/
dist/
*.d.ts.map
*.tsbuildinfo
+198
View File
@@ -0,0 +1,198 @@
name: "solve-issue"
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
roles:
planner:
description: "Analyzes issue and outputs a TDD test spec"
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
capabilities:
- issue-analysis
- planning
procedure: |
On first run (no previous steps):
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> -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):
1. Read the tester's output from the previous step to understand what's wrong with the spec
2. Revise the test spec accordingly
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)
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:
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."
capabilities:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The repo path and other details are provided in your task prompt.
Before starting any work, set up an isolated worktree:
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`
5. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
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:
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)."
capabilities:
- code-review
- static-analysis
procedure: |
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
2. If the branch doesn't correspond to the issue, flag it in your output and reject
Then perform code review:
Hard checks (must all pass):
3. `bun run build` — no build errors
4. `bunx biome check` — no lint violations
5. TypeScript strict mode — no type errors
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. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
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: |
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 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. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
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: |
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 "..."`
- 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 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:
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", prompt: "Analyze the issue and produce an implementation plan." }
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:
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
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:
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:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
+77
View File
@@ -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
+133 -1
View File
@@ -1,3 +1,135 @@
# json-cas
Self-describing content-addressable storage with JSON Schema typed nodes
Self-describing content-addressable storage with JSON Schema typed nodes.
## Overview
json-cas is a monorepo for storing and validating JSON data in a content-addressable store (CAS). Each node has a typed payload: its `type` field is the hash of a JSON Schema node that describes the payload shape. Hashes are 13-character Crockford Base32 strings derived from XXH64 over deterministic CBOR encoding.
A bootstrap meta-schema is stored as a self-referencing seed node (`type === hash`). All other schemas are registered as nodes typed by that meta-schema. Payloads can reference other nodes via `format: "cas_ref"` fields; the library provides traversal, reference extraction, and integrity verification.
Use the in-memory store for tests and embedded apps, the filesystem store for persistence, and the CLI for local store management.
## Architecture
```
┌─────────────────┐
│ cli-json-cas │
└────────┬────────┘
┌─────────────────┐
│ json-cas-fs │
└────────┬────────┘
┌─────────────────┐
│ json-cas │ (core)
└─────────────────┘
```
| Layer | Package | Role |
|-------|---------|------|
| Core | `@uncaged/json-cas` | Hashing, schemas, stores, verify, bootstrap |
| Storage | `@uncaged/json-cas-fs` | Filesystem-backed `Store` |
| CLI | `@uncaged/cli-json-cas` | `json-cas` command-line tool |
## Packages
| Package | Description | Type |
|---------|-------------|------|
| [`@uncaged/json-cas`](packages/json-cas/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
| [`@uncaged/json-cas-fs`](packages/json-cas-fs/README.md) | Filesystem-backed CAS store | lib |
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`json-cas` binary) | cli |
## Quick Start
```bash
git clone <repo-url>
cd json-cas
bun install --no-cache
bun run build
```
```typescript
import {
bootstrap,
createMemoryStore,
putSchema,
validate,
} from "@uncaged/json-cas";
const store = createMemoryStore();
await bootstrap(store);
const typeHash = await putSchema(store, {
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
additionalProperties: false,
});
const hash = await store.put(typeHash, { message: "hello" });
const node = store.get(hash);
console.log(validate(store, node!)); // true
```
For a persistent store:
```typescript
import { createFsStore } from "@uncaged/json-cas-fs";
import { bootstrap } from "@uncaged/json-cas";
const store = createFsStore("/path/to/store");
await bootstrap(store);
```
Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/README.md`](packages/cli-json-cas/README.md)).
## CLI Reference
Binary: `json-cas` (from `@uncaged/cli-json-cas`). Default store: `~/.uncaged/json-cas`.
```
Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
init Create store dir and write bootstrap seed
bootstrap Write meta-schema seed, print hash
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
schema validate <hash> Validate node against its schema
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--json Compact JSON output
```
## Development
```bash
bun install --no-cache # install workspace dependencies
bun run build # tsc --build (libs)
bun run check # biome check
bun run format # biome format --write
bun test # run all package tests
```
## Publishing
Releases use [Changesets](https://github.com/changesets/changesets). From the repo root:
```bash
bun run release # changeset version → build → publish to npm (@uncaged/*)
```
Individual packages block `prepublishOnly` and expect releases via the workspace `release` script.
+8 -12
View File
@@ -10,11 +10,12 @@
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0",
"ulidx": "^2.4.1",
},
},
"packages/cli-json-cas": {
"name": "@uncaged/cli-json-cas",
"version": "0.1.0",
"version": "0.5.3",
"bin": {
"json-cas": "./src/index.ts",
},
@@ -25,7 +26,7 @@
},
"packages/json-cas": {
"name": "@uncaged/json-cas",
"version": "0.1.0",
"version": "0.5.3",
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
@@ -34,19 +35,12 @@
},
"packages/json-cas-fs": {
"name": "@uncaged/json-cas-fs",
"version": "0.1.0",
"version": "0.5.3",
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"cborg": "^4.2.3",
},
},
"packages/json-cas-workflow": {
"name": "@uncaged/json-cas-workflow",
"version": "0.1.0",
"dependencies": {
"@uncaged/json-cas": "workspace:^",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
@@ -127,8 +121,6 @@
"@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@workspace:packages/json-cas-fs"],
"@uncaged/json-cas-workflow": ["@uncaged/json-cas-workflow@workspace:packages/json-cas-workflow"],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
@@ -209,6 +201,8 @@
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
@@ -291,6 +285,8 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ulidx": ["ulidx@2.4.1", "", { "dependencies": { "layerr": "^3.0.0" } }, "sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+68
View File
@@ -0,0 +1,68 @@
# Sync README
When updating README.md files in this monorepo, follow these conventions.
## Scope
- Root `README.md` — project overview and navigation hub
- Per-package `packages/*/README.md` — each package self-contained
## Root README Structure
The root README should have these sections in order:
1. **Title and one-liner** — content-addressed storage for JSON with schema validation
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
3. **Architecture** — dependency layer diagram (text-based)
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib)
5. **Quick Start** — install, build, basic usage
6. **CLI Reference** — brief command list, detailed usage in cli-json-cas README
7. **Development** — bun install / build / check / test
8. **Publishing** — changeset workflow (bun run release)
## Per-Package README Structure
Each package README should have:
1. **Title** — package name
2. **One-line description** — matching package.json
3. **Overview** — what it does, where it sits in the architecture, dependencies
4. **Installation** — bun add (for libs) or "included as binary" (for cli)
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
6. **CLI Usage** (cli packages) — command reference with examples
7. **Internal Structure** — brief src/ file organization
8. **Configuration** (if applicable)
## Execution Steps
### Step 1: Gather current state
For each package read:
- package.json (name, version, description, dependencies, bin)
- src/index.ts (public API exports)
- Existing README.md (preserve hand-written content worth keeping)
### Step 2: Update root README
- Ensure ALL packages in packages/ directory are listed in the table
- Update CLI command reference from actual --help output
- Keep Quick Start examples valid
### Step 3: Write/update each package README
- Follow the per-package structure
- API section MUST match actual src/index.ts exports — never invent
- For cli packages: document CLI binary name, how it is invoked
- For lib packages: document exported types and functions
- Internal structure: list actual files in src/
### Step 4: Verify
- All relative links work
- Package names match package.json
- No references to removed/renamed packages
- bun run build still passes
## Guidelines
- Only document what src/index.ts actually exports
- Root README summarizes, package READMEs go into detail
- Verify CLI examples against actual commands
- Preserve existing good prose when updating
- English for all README content
+5 -2
View File
@@ -9,11 +9,14 @@
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"ulidx": "^2.4.1"
},
"scripts": {
"build": "tsc --build packages/json-cas packages/json-cas-fs",
"test": "bun test",
"check": "biome check .",
"format": "biome format --write ."
"format": "biome format --write .",
"release": "changeset version && bun run build && changeset publish"
}
}
+35
View File
@@ -0,0 +1,35 @@
# @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
- Updated dependencies []:
- @uncaged/json-cas@0.3.0
- @uncaged/json-cas-fs@0.3.0
## 0.2.0
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.2.0
- @uncaged/json-cas-fs@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
- @uncaged/json-cas-fs@0.1.3
+98
View File
@@ -0,0 +1,98 @@
# @uncaged/cli-json-cas
CLI tool for json-cas stores.
## Overview
`@uncaged/cli-json-cas` provides the `json-cas` command for managing a filesystem-backed store: bootstrap, schema registration, node CRUD, integrity checks, reference listing, and graph walks. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
## Installation
Published as an npm package with a binary entry:
```bash
bun add -g @uncaged/cli-json-cas
# or from the monorepo workspace:
bun link
```
**Binary name:** `json-cas` (points to `src/index.ts`, run with Bun).
In development:
```bash
bun packages/cli-json-cas/src/index.ts <command> [args]
```
## CLI Usage
```
Usage: json-cas [--store <path>] [--json] <command> [args]
```
### Global flags
| Flag | Description |
|------|-------------|
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
| `--json` | Compact JSON output for commands that print JSON |
### Commands
| Command | Description |
|---------|-------------|
| `init` | Create store directory and write bootstrap seed; prints meta hash |
| `bootstrap` | Write meta-schema seed into existing store; prints hash |
| `schema put <file.json>` | Register schema from file; prints type hash |
| `schema get <type-hash>` | Print schema JSON |
| `schema list` | List all schemas (`hash name`) |
| `schema validate <hash>` | Validate node against its schema; prints `valid` / `invalid` |
| `put <type-hash> <file.json>` | Store node; prints content hash |
| `get <hash>` | Print full node as JSON |
| `has <hash>` | Print `true` or `false` |
| `verify <hash>` | Verify integrity; prints `ok` or `corrupted` |
| `refs <hash>` | Print direct `cas_ref` targets (one per line) |
| `walk <hash>` | BFS traversal; one hash per line |
| `walk <hash> --format tree` | Tree-formatted traversal |
| `hash <type-hash> <file.json>` | Compute hash without storing |
| `cat <hash>` | Print node JSON |
| `cat <hash> --payload` | Print payload only |
### Examples
```bash
# Initialize default store at ~/.uncaged/json-cas
json-cas init
# Use a custom store path
json-cas --store ./data/cas bootstrap
# Register a schema and store a payload
json-cas schema put ./schemas/item.json
# → prints type hash, e.g. 0123456789ABCD
json-cas put 0123456789ABCD ./payloads/item.json
# → prints content hash
json-cas get <content-hash> --json
json-cas verify <content-hash>
json-cas walk <content-hash> --format tree
```
## Internal Structure
| File | Purpose |
|------|---------|
| `index.ts` | Argument parsing, command dispatch, and all CLI logic |
There is no separate `src/` module tree; the CLI is a single entry file. Tests (if present) are co-located under the package.
## Configuration
| Setting | Default | Override |
|---------|---------|----------|
| Store directory | `~/.uncaged/json-cas` | `--store <path>` |
No config file is read; all behavior is controlled via flags and command arguments.
+5 -4
View File
@@ -1,15 +1,16 @@
{
"name": "@uncaged/cli-json-cas",
"version": "0.1.1",
"version": "0.5.3",
"type": "module",
"bin": {
"json-cas": "./src/index.ts"
},
"scripts": {
"test": "bun test"
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"@uncaged/json-cas-fs": "workspace:^"
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3"
}
}
+200 -18
View File
@@ -1,15 +1,20 @@
#!/usr/bin/env bun
import { mkdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { homedir } from "node:os";
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
import { join, resolve } from "node:path";
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
import {
bootstrap,
CasNodeNotFoundError,
computeHash,
createVariableStore,
getSchema,
InvalidScopeError,
putSchema,
refs,
SchemaMismatchError,
VariableNotFoundError,
validate,
verify,
walk,
@@ -21,7 +26,7 @@ import { createFsStore } from "@uncaged/json-cas-fs";
type Flags = Record<string, string | boolean>;
/** Flags that consume the next token as their value. All others are boolean. */
const VALUE_FLAGS = new Set(["store", "format"]);
const VALUE_FLAGS = new Set(["store", "format", "scope", "value", "var-db"]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
const flags: Flags = {};
@@ -53,9 +58,14 @@ 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;
const defaultVarDbPath = join(storePath, "variables.db");
const varDbPath =
typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath;
// ---- Helpers ----
function out(data: unknown): void {
@@ -79,6 +89,52 @@ function openStore(): Store {
return createFsStore(resolve(storePath));
}
function openVarStore(): VariableStore {
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
return createVariableStore(resolve(varDbPath), store);
}
/**
* Get the Variable schema's CAS hash
* This is the type hash used in JSON envelopes
*/
async function getVariableSchemaHash(): Promise<Hash> {
const store = openStore();
// Define the Variable JSON Schema (simple version for envelope)
const variableSchema: JSONSchema = {
title: "Variable",
type: "object",
properties: {
id: { type: "string" },
scope: { type: "string" },
value: { type: "string" },
schema: { type: "string" },
created: { type: "number" },
updated: { type: "number" },
},
required: ["id", "scope", "value", "schema", "created", "updated"],
};
// Compute hash or retrieve from store
const hash = await putSchema(store, variableSchema);
return hash;
}
/**
* Wrap Variable output in JSON envelope
*/
async function wrapVariableEnvelope(
variable: unknown,
): Promise<{ type: Hash; value: unknown }> {
const typeHash = await getVariableSchemaHash();
return {
type: typeHash,
value: variable,
};
}
// ---- Commands ----
async function cmdInit(): Promise<void> {
@@ -116,10 +172,10 @@ async function cmdSchemaGet(args: string[]): Promise<void> {
async function cmdSchemaList(): Promise<void> {
const store = openStore();
const metaHash = await bootstrap(store);
for (const hash of store.list()) {
for (const hash of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null && node.type === metaHash) {
if (node !== null) {
const schema = node.payload as JSONSchema;
const name =
(schema.title as string | undefined) ??
@@ -176,13 +232,6 @@ async function cmdVerify(args: string[]): Promise<void> {
console.log(ok ? "ok" : "corrupted");
}
async function cmdList(): Promise<void> {
const store = openStore();
for (const hash of store.list()) {
console.log(hash);
}
}
async function cmdRefs(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas refs <hash>");
@@ -256,6 +305,114 @@ async function cmdCat(args: string[]): Promise<void> {
}
}
async function cmdVarCreate(_args: string[]): Promise<void> {
const scope = flags.scope as string | undefined;
const value = flags.value as string | undefined;
if (!scope) die("Usage: json-cas var create --scope <scope> --value <hash>");
if (!value) die("Usage: json-cas var create --scope <scope> --value <hash>");
const varStore = openVarStore();
try {
const variable = varStore.create(scope, value);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (e instanceof InvalidScopeError || e instanceof CasNodeNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarGet(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var get <id>");
const varStore = openVarStore();
try {
const variable = varStore.get(id);
if (variable === null) {
die(`Error: Variable not found: ${id}`);
}
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} finally {
varStore.close();
}
}
async function cmdVarUpdate(args: string[]): Promise<void> {
const id = args[0];
const value = args[1];
if (!id || !value) {
die("Usage: json-cas var update <id> <hash>");
}
const varStore = openVarStore();
try {
const variable = varStore.update(id, value);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
e instanceof SchemaMismatchError ||
e instanceof CasNodeNotFoundError
) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarDelete(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var delete <id>");
const varStore = openVarStore();
try {
const variable = varStore.delete(id);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarList(_args: string[]): Promise<void> {
const scope = (flags.scope as string | undefined) ?? "";
const varStore = openVarStore();
try {
const variables = varStore.list({ scope });
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
} catch (e) {
if (e instanceof InvalidScopeError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
@@ -271,14 +428,19 @@ Commands:
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
list List all hashes
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)
var create --scope <s> --value <h> Create a variable
var get <id> Get a variable by ID
var update <id> <hash> Update variable value
var delete <id> Delete a variable
var list [--scope <prefix>] List variables (optionally filter by scope prefix)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output`);
}
@@ -337,10 +499,6 @@ switch (cmd) {
await cmdVerify(rest);
break;
case "list":
await cmdList();
break;
case "refs":
await cmdRefs(rest);
break;
@@ -357,6 +515,30 @@ switch (cmd) {
await cmdCat(rest);
break;
case "var": {
const [sub, ...subRest] = rest;
switch (sub) {
case "create":
await cmdVarCreate(subRest);
break;
case "get":
await cmdVarGet(subRest);
break;
case "update":
await cmdVarUpdate(subRest);
break;
case "delete":
await cmdVarDelete(subRest);
break;
case "list":
await cmdVarList(subRest);
break;
default:
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
}
break;
}
default:
die(`Unknown command: ${cmd}`);
}
+822
View File
@@ -0,0 +1,822 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
describe("CLI var commands", () => {
let storePath: string;
let varDbPath: string;
let cliPath: string;
let schemaHash: string;
let hashA: string;
let hashB: string;
let testCounter = 0;
beforeEach(async () => {
// Create temporary paths with counter to ensure uniqueness
testCounter++;
storePath = join(tmpdir(), `test-cli-store-${Date.now()}-${testCounter}`);
varDbPath = join(storePath, "variables.db");
cliPath = join(import.meta.dir, "index.ts");
// Initialize store and create test data
const initResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "init"],
{
encoding: "utf-8",
},
);
expect(initResult.status).toBe(0);
// Create a schema
const schemaFile = join(tmpdir(), `schema-${Date.now()}.json`);
await Bun.write(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaPutResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "schema", "put", schemaFile],
{ encoding: "utf-8" },
);
expect(schemaPutResult.status).toBe(0);
schemaHash = schemaPutResult.stdout.trim();
// Create test CAS nodes
const dataFileA = join(tmpdir(), `data-a-${Date.now()}.json`);
await Bun.write(dataFileA, JSON.stringify({ name: "hello" }));
const putResultA = spawnSync(
"bun",
[cliPath, "--store", storePath, "put", schemaHash, dataFileA],
{ encoding: "utf-8" },
);
expect(putResultA.status).toBe(0);
hashA = putResultA.stdout.trim();
const dataFileB = join(tmpdir(), `data-b-${Date.now()}.json`);
await Bun.write(dataFileB, JSON.stringify({ name: "world" }));
const putResultB = spawnSync(
"bun",
[cliPath, "--store", storePath, "put", schemaHash, dataFileB],
{ encoding: "utf-8" },
);
expect(putResultB.status).toBe(0);
hashB = putResultB.stdout.trim();
});
afterEach(() => {
// Cleanup
try {
unlinkSync(varDbPath);
} catch {
// Ignore
}
});
describe("Test Group 1: Variable Creation", () => {
test("1.1: Create variable with valid scope", () => {
const result = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaHash);
expect(variable.created).toBeGreaterThan(Date.now() - 5000);
expect(variable.updated).toBe(variable.created);
});
test("1.2: Create variable fails with scope not ending in /", () => {
const result = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("scope must end with /");
});
test("1.3: Create variable fails with non-existent CAS node", () => {
const fakeHash = "FAKEHASH00000";
const result = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/",
"--value",
fakeHash,
],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("CAS node not found");
});
});
describe("Test Group 2: Variable Retrieval", () => {
test("2.1: Get existing variable", () => {
// Create a variable first
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Get the variable
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", created.id],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaHash);
});
test("2.2: Get non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", fakeId],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("Variable not found");
});
});
describe("Test Group 3: Variable Update (Schema Consistent)", () => {
test("3.1: Update variable with matching schema", async () => {
// Create a variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Wait a bit to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
// Update the variable
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "update", created.id, hashB],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
expect(variable.value).toBe(hashB);
expect(variable.schema).toBe(schemaHash);
expect(variable.updated).toBeGreaterThan(created.created);
});
});
describe("Test Group 4: Variable Update (Schema Mismatch)", () => {
test("4.1: Update variable fails with schema mismatch", async () => {
// Create another schema
const schema2File = join(tmpdir(), `schema2-${Date.now()}.json`);
await Bun.write(
schema2File,
JSON.stringify({
type: "object",
properties: { count: { type: "number" } },
}),
);
const schema2PutResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "schema", "put", schema2File],
{ encoding: "utf-8" },
);
const schemaHash2 = schema2PutResult.stdout.trim();
// Create a node with the second schema
const dataFileC = join(tmpdir(), `data-c-${Date.now()}.json`);
await Bun.write(dataFileC, JSON.stringify({ count: 42 }));
const putResultC = spawnSync(
"bun",
[cliPath, "--store", storePath, "put", schemaHash2, dataFileC],
{ encoding: "utf-8" },
);
const hashC = putResultC.stdout.trim();
// Create a variable with first schema
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Try to update with different schema
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "update", created.id, hashC],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toContain("schema mismatch");
});
});
describe("Test Group 5: Variable Deletion", () => {
test("5.1: Delete existing variable", () => {
// Create a variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Delete the variable
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "delete", created.id],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
// Verify it's deleted
const getResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", created.id],
{ encoding: "utf-8" },
);
expect(getResult.status).not.toBe(0);
});
test("5.3: Delete non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "delete", fakeId],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("Variable not found");
});
});
describe("Test Group 6: Variable Listing", () => {
test("6.1: List variables with scope prefix", () => {
// Create variables with different scopes
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult1.status).toBe(0);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
expect(createResult2.status).toBe(0);
const var2 = JSON.parse(createResult2.stdout).value;
const createResult3 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/agent/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult3.status).toBe(0);
const var3 = JSON.parse(createResult3.stdout).value;
const createResult4 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"app/config/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult4.status).toBe(0);
// List all variables with uwf/ prefix
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list", "--scope", "uwf/"],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
// Check the actual variables in the value field
const variables = output.value;
expect(variables).toHaveLength(3);
expect(
variables.every((v: { scope: string }) => v.scope.startsWith("uwf/")),
).toBe(true);
// Verify ordering by created timestamp
expect(variables[0].id).toBe(var1.id);
expect(variables[1].id).toBe(var2.id);
expect(variables[2].id).toBe(var3.id);
});
test("6.2: List all variables when no scope specified", () => {
// Create variables with different scopes
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult1.status).toBe(0);
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"app/config/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
expect(createResult2.status).toBe(0);
// List all variables without scope filter
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list"],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
const variables = output.value;
expect(variables).toHaveLength(2);
});
test("6.3: List returns empty array when no matches", () => {
// Create a variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult.status).toBe(0);
// List with non-matching scope
const listResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"list",
"--scope",
"nonexistent/",
],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
expect(output.value).toHaveLength(0);
});
test("6.4: List fails with invalid scope format", () => {
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list", "--scope", "uwf"],
{ encoding: "utf-8" },
);
expect(listResult.status).not.toBe(0);
expect(listResult.stderr).toContain("scope must end with /");
});
});
describe("Test Group 7: Integration Tests", () => {
test("7.1: Full lifecycle workflow", async () => {
// Create variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult.status).toBe(0);
const var1 = JSON.parse(createResult.stdout).value;
expect(var1.value).toBe(hashA);
// Get variable
const getResult1 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(getResult1.status).toBe(0);
const retrieved1 = JSON.parse(getResult1.stdout).value;
expect(retrieved1.value).toBe(hashA);
// Wait to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
// Update variable
const updateResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "update", var1.id, hashB],
{ encoding: "utf-8" },
);
expect(updateResult.status).toBe(0);
const updated = JSON.parse(updateResult.stdout).value;
expect(updated.value).toBe(hashB);
// Get updated variable
const getResult2 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(getResult2.status).toBe(0);
const retrieved2 = JSON.parse(getResult2.stdout).value;
expect(retrieved2.value).toBe(hashB);
// Delete variable
const deleteResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "delete", var1.id],
{ encoding: "utf-8" },
);
expect(deleteResult.status).toBe(0);
// Verify deletion
const getResult3 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(getResult3.status).not.toBe(0);
});
test("7.2: Multiple variables with same scope", () => {
// Create two variables
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
const var2 = JSON.parse(createResult2.stdout).value;
// Verify independence
expect(var1.id).not.toBe(var2.id);
const getResult1 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
const retrieved1 = JSON.parse(getResult1.stdout).value;
expect(retrieved1.value).toBe(hashA);
const getResult2 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var2.id],
{ encoding: "utf-8" },
);
const retrieved2 = JSON.parse(getResult2.stdout).value;
expect(retrieved2.value).toBe(hashB);
// Delete var1, verify var2 still exists
spawnSync("bun", [
cliPath,
"--store",
storePath,
"var",
"delete",
var1.id,
]);
const getResult2Final = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var2.id],
{ encoding: "utf-8" },
);
expect(getResult2Final.status).toBe(0);
const retrieved2Final = JSON.parse(getResult2Final.stdout).value;
expect(retrieved2Final.value).toBe(hashB);
});
test("7.3: Variables with hierarchical scopes", () => {
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var2 = JSON.parse(createResult2.stdout).value;
const createResult3 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/workflow/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var3 = JSON.parse(createResult3.stdout).value;
expect(var1.scope).toBe("uwf/");
expect(var2.scope).toBe("uwf/thread/");
expect(var3.scope).toBe("uwf/workflow/");
// Verify all exist
const get1 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(get1.status).toBe(0);
const get2 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var2.id],
{ encoding: "utf-8" },
);
expect(get2.status).toBe(0);
const get3 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var3.id],
{ encoding: "utf-8" },
);
expect(get3.status).toBe(0);
});
});
});
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
# @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
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.3.0
## 0.2.0
### Minor Changes
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.2.0
## 0.1.3
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.1.3
+67
View File
@@ -0,0 +1,67 @@
# @uncaged/json-cas-fs
Filesystem-backed CAS store.
## Overview
`@uncaged/json-cas-fs` implements a persistent `Store` on disk. Each node is stored as `<hash>.bin` (CBOR-encoded `CasNode`). A `_index/` directory maps type hashes to content hashes for `listByType`. Stores support bootstrap via the same `BOOTSTRAP_STORE` symbol as the in-memory implementation.
Depends on `@uncaged/json-cas` for hashing, CBOR encoding, and types.
**Dependencies:** `@uncaged/json-cas`, `cborg`
## Installation
```bash
bun add @uncaged/json-cas-fs
```
## API
Exported from `src/index.ts`:
```typescript
function createFsStore(dir: string): BootstrapCapableStore;
```
Returns a `BootstrapCapableStore` from `@uncaged/json-cas`. The store loads existing `.bin` files on open and migrates or builds the type index on first use.
### Example
```typescript
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
const store = createFsStore("./my-cas-store");
await bootstrap(store);
const typeHash = await putSchema(store, {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
});
const hash = await store.put(typeHash, { id: "item-1" });
console.log(store.has(hash)); // true after restart if same dir
```
### On-disk layout
```
my-cas-store/
├── <hash>.bin # CBOR CasNode
├── _index/
│ └── <typeHash> # newline-separated content hashes
└── ...
```
Writes use atomic rename (`<hash>.tmp``<hash>.bin`).
## Internal Structure
| File | Purpose |
|------|---------|
| `store.ts` | `createFsStore`, load/save nodes and type index |
| `index.ts` | Public export |
| `store.test.ts` | Filesystem store tests |
+14 -5
View File
@@ -1,16 +1,25 @@
{
"name": "@uncaged/json-cas-fs",
"version": "0.1.1",
"version": "0.5.3",
"type": "module",
"main": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"test": "bun test"
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"@uncaged/json-cas": "^0.5.3",
"cborg": "^4.2.3"
}
}
+92 -11
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasNode } from "@uncaged/json-cas";
@@ -30,15 +30,15 @@ describe("createFsStore – init and bootstrap", () => {
test("store opens against an existing empty dir", () => {
const store = createFsStore(dir);
expect(store.list()).toEqual([]);
expect(store.listByType("0000000000000")).toEqual([]);
});
test("store creates the directory on first put", async () => {
const nested = join(dir, "sub", "store");
const store = createFsStore(nested);
const typeHash = await computeSelfHash({ name: "t" });
await store.put(typeHash, { x: 1 });
expect(store.list()).toHaveLength(1);
const hash = await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
});
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
@@ -58,7 +58,7 @@ describe("createFsStore – init and bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toBe(h2);
expect(store.list()).toHaveLength(1);
expect(store.listByType(h1)).toHaveLength(1);
});
});
@@ -84,7 +84,7 @@ describe("createFsStore – persistence round-trip", () => {
const store2 = createFsStore(dir);
expect(store2.has(h1)).toBe(true);
expect(store2.has(h2)).toBe(true);
expect(store2.list()).toHaveLength(2);
expect(store2.listByType(typeHash)).toHaveLength(2);
});
test("round-trip preserves type, payload, and timestamp", async () => {
@@ -125,7 +125,7 @@ describe("createFsStore – persistence round-trip", () => {
const ts2 = store2.get(hash)?.timestamp;
expect(ts1).toBe(ts2);
expect(store2.list()).toHaveLength(1);
expect(store2.listByType(typeHash)).toHaveLength(1);
});
});
@@ -151,7 +151,7 @@ describe("createFsStore – has and list", () => {
expect(store.has(hash)).toBe(true);
});
test("list returns all stored hashes", async () => {
test("listByType returns all stored hashes for a type", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
@@ -159,16 +159,16 @@ describe("createFsStore – has and list", () => {
const h2 = await store.put(typeHash, { a: 2 });
const h3 = await store.put(typeHash, { a: 3 });
const all = store.list();
const all = store.listByType(typeHash);
expect(all).toHaveLength(3);
expect(all).toContain(h1);
expect(all).toContain(h2);
expect(all).toContain(h3);
});
test("list returns empty array on fresh store", () => {
test("listByType returns empty array on fresh store", () => {
const store = createFsStore(dir);
expect(store.list()).toEqual([]);
expect(store.listByType("0000000000000")).toEqual([]);
});
test("get returns null for unknown hash", () => {
@@ -177,6 +177,87 @@ describe("createFsStore – has and list", () => {
});
});
// ──────────────────────────────────────────────────────────────────────────────
// listByType and index migration
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – listByType", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("returns empty array for unknown type", () => {
const store = createFsStore(dir);
expect(store.listByType("0000000000000")).toEqual([]);
});
test("returns all hashes for the given type", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
const otherType = await computeSelfHash({ name: "other" });
const h1 = await store.put(typeHash, { a: 1 });
const h2 = await store.put(typeHash, { a: 2 });
await store.put(otherType, { b: 1 });
const byType = store.listByType(typeHash);
expect(byType).toHaveLength(2);
expect(byType).toContain(h1);
expect(byType).toContain(h2);
});
test("listByType survives round-trip across store instances", async () => {
const typeHash = await computeSelfHash({ name: "persist-by-type" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { x: 1 });
const h2 = await store1.put(typeHash, { x: 2 });
const store2 = createFsStore(dir);
const byType = store2.listByType(typeHash);
expect(byType).toHaveLength(2);
expect(byType).toContain(h1);
expect(byType).toContain(h2);
});
test("idempotent put does not duplicate in listByType", async () => {
const typeHash = await computeSelfHash({ name: "idempotent-index" });
const store1 = createFsStore(dir);
const hash = await store1.put(typeHash, { n: 7 });
await store1.put(typeHash, { n: 7 });
const store2 = createFsStore(dir);
expect(store2.listByType(typeHash)).toEqual([hash]);
});
test("rebuilds _index from .bin files when index is missing", async () => {
const typeHash = await computeSelfHash({ name: "migrate" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { a: 1 });
const h2 = await store1.put(typeHash, { a: 2 });
rmSync(join(dir, "_index"), { recursive: true, force: true });
const store2 = createFsStore(dir);
expect(store2.listByType(typeHash)).toEqual([h1, h2]);
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
expect(readdirSync(join(dir, "_index"))).toContain(typeHash);
});
test("bootstrap node is listed under its self type after reload", async () => {
const store1 = createFsStore(dir);
const hash = await bootstrap(store1);
const store2 = createFsStore(dir);
expect(store2.listByType(hash)).toEqual([hash]);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// verify on disk-loaded nodes
// ──────────────────────────────────────────────────────────────────────────────
+121 -14
View File
@@ -1,4 +1,6 @@
import {
appendFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
@@ -6,11 +8,18 @@ import {
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type { CasNode, Hash, Store } from "@uncaged/json-cas";
import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
import { cborEncode, computeHash, computeSelfHash } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
cborEncode,
computeHash,
computeSelfHash,
} from "@uncaged/json-cas";
import { decode } from "cborg";
const INDEX_DIR = "_index";
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
let entries: string[];
try {
@@ -31,20 +40,112 @@ function loadDir(dir: string, data: Map<Hash, CasNode>): void {
}
}
export function createFsStore(dir: string): Store {
function parseIndexFile(content: string): Hash[] {
if (content.length === 0) return [];
return content.split("\n").filter((line) => line.length > 0) as Hash[];
}
function loadTypeIndex(indexDir: string): Map<Hash, Hash[]> {
const typeIndex = new Map<Hash, Hash[]>();
let entries: string[];
try {
entries = readdirSync(indexDir);
} catch {
return typeIndex;
}
for (const typeHash of entries) {
try {
const content = readFileSync(join(indexDir, typeHash), "utf8");
typeIndex.set(typeHash as Hash, parseIndexFile(content));
} catch {
// skip unreadable index files
}
}
return typeIndex;
}
function buildTypeIndexFromNodes(data: Map<Hash, CasNode>): Map<Hash, Hash[]> {
const typeIndex = new Map<Hash, Hash[]>();
for (const [hash, node] of data) {
const list = typeIndex.get(node.type) ?? [];
list.push(hash);
typeIndex.set(node.type, list);
}
return typeIndex;
}
function writeTypeIndex(indexDir: string, typeIndex: Map<Hash, Hash[]>): void {
mkdirSync(indexDir, { recursive: true });
for (const [typeHash, hashes] of typeIndex) {
const body = hashes.length > 0 ? `${hashes.join("\n")}\n` : "";
writeFileSync(join(indexDir, typeHash), body, "utf8");
}
}
function loadOrMigrateTypeIndex(
dir: string,
data: Map<Hash, CasNode>,
): Map<Hash, Hash[]> {
const indexDir = join(dir, INDEX_DIR);
if (!existsSync(indexDir)) {
const typeIndex = buildTypeIndexFromNodes(data);
if (typeIndex.size > 0) {
writeTypeIndex(indexDir, typeIndex);
}
return typeIndex;
}
return loadTypeIndex(indexDir);
}
function appendToTypeIndex(
indexDir: string,
typeIndex: Map<Hash, Hash[]>,
type: Hash,
hash: Hash,
): void {
mkdirSync(indexDir, { recursive: true });
appendFileSync(join(indexDir, type), `${hash}\n`, "utf8");
const list = typeIndex.get(type) ?? [];
list.push(hash);
typeIndex.set(type, list);
}
export function createFsStore(dir: string): BootstrapCapableStore {
const data = new Map<Hash, CasNode>();
loadDir(dir, data);
const indexDir = join(dir, INDEX_DIR);
const typeIndex = loadOrMigrateTypeIndex(dir, data);
return {
async put(typeHash: Hash | null, payload: unknown): Promise<Hash> {
const hash =
typeHash === null
? await computeSelfHash(payload)
: await computeHash(typeHash, payload);
async function putSelfReferencing(payload: unknown): Promise<Hash> {
const hash = await computeSelfHash(payload);
if (!data.has(hash)) {
const node: CasNode = { type: hash, payload, timestamp: Date.now() };
data.set(hash, node);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `${hash}.tmp`);
const dest = join(dir, `${hash}.bin`);
writeFileSync(
tmp,
cborEncode({ type: hash, payload, timestamp: node.timestamp }),
);
renameSync(tmp, dest);
appendToTypeIndex(indexDir, typeIndex, hash, hash);
}
return hash;
}
const store: BootstrapCapableStore = {
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
const hash = await computeHash(typeHash, payload);
if (!data.has(hash)) {
const type = typeHash === null ? hash : typeHash;
const node: CasNode = { type, payload, timestamp: Date.now() };
const node: CasNode = {
type: typeHash,
payload,
timestamp: Date.now(),
};
data.set(hash, node);
mkdirSync(dir, { recursive: true });
@@ -52,9 +153,11 @@ export function createFsStore(dir: string): Store {
const dest = join(dir, `${hash}.bin`);
writeFileSync(
tmp,
cborEncode({ type, payload, timestamp: node.timestamp }),
cborEncode({ type: typeHash, payload, timestamp: node.timestamp }),
);
renameSync(tmp, dest);
appendToTypeIndex(indexDir, typeIndex, typeHash, hash);
}
return hash;
@@ -68,8 +171,12 @@ export function createFsStore(dir: string): Store {
return data.has(hash);
},
list(): Hash[] {
return [...data.keys()];
listByType(typeHash: Hash): Hash[] {
return typeIndex.get(typeHash) ?? [];
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
return store;
}
+3 -1
View File
@@ -4,5 +4,7 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"],
"references": [{ "path": "../json-cas" }]
}
-15
View File
@@ -1,15 +0,0 @@
{
"name": "@uncaged/json-cas-workflow",
"version": "0.1.1",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "workspace:^"
}
}
@@ -1,646 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { CasNode } from "@uncaged/json-cas";
import {
createMemoryStore,
getSchema,
refs,
validate,
walk,
} from "@uncaged/json-cas";
import type { WorkflowSchemaHashes } from "./schemas.js";
import { registerWorkflowSchemas } from "./schemas.js";
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: registerWorkflowSchemas() — registers all 11 schemas
// ─────────────────────────────────────────────────────────────────────────────
describe("registerWorkflowSchemas", () => {
test("returns an object with all 11 schema hashes", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
const keys: (keyof WorkflowSchemaHashes)[] = [
"agent",
"roleSchema",
"role",
"workflow",
"threadStart",
"threadStep",
"threadEnd",
"content",
"reactSession",
"reactTurn",
"reactToolCall",
];
expect(Object.keys(hashes)).toHaveLength(11);
for (const key of keys) {
expect(hashes[key]).toBeDefined();
}
});
test("all hashes are valid 13-char Crockford Base32 strings", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
for (const hash of Object.values(hashes)) {
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("all 11 hashes are distinct", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
const values = Object.values(hashes);
const unique = new Set(values);
expect(unique.size).toBe(11);
});
test("is idempotent: repeated calls return the same hashes", async () => {
const store = createMemoryStore();
const first = await registerWorkflowSchemas(store);
const second = await registerWorkflowSchemas(store);
for (const key of Object.keys(first) as (keyof WorkflowSchemaHashes)[]) {
expect(first[key]).toBe(second[key]);
}
});
test("schemas are stored in the store (getSchema returns non-null)", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
for (const hash of Object.values(hashes)) {
expect(getSchema(store, hash)).not.toBeNull();
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: getSchema() — schema round-trip for each of the 11 types
// ─────────────────────────────────────────────────────────────────────────────
describe("getSchema round-trip", () => {
test("agent schema has the expected properties", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const schema = getSchema(store, agent);
expect(schema).not.toBeNull();
expect(schema?.type).toBe("object");
const props = schema?.properties as Record<string, unknown>;
expect(props).toHaveProperty("package");
expect(props).toHaveProperty("version");
expect(props).toHaveProperty("config");
});
test("role schema references cas_ref for the schema field", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const schema = getSchema(store, role);
expect(schema).not.toBeNull();
const props = schema?.properties as Record<string, { format?: string }>;
expect(props.schema?.format).toBe("cas_ref");
});
test("thread-step schema has six required fields", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const schema = getSchema(store, threadStep);
expect(schema?.required).toHaveLength(6);
});
test("react-turn schema has nested tokens object", async () => {
const store = createMemoryStore();
const { reactTurn } = await registerWorkflowSchemas(store);
const schema = getSchema(store, reactTurn);
const props = schema?.properties as Record<
string,
{ type: string; properties?: unknown }
>;
expect(props.tokens?.type).toBe("object");
expect(props.tokens?.properties).toBeDefined();
});
test("workflow schema has roles with additionalProperties cas_ref", async () => {
const store = createMemoryStore();
const { workflow } = await registerWorkflowSchemas(store);
const schema = getSchema(store, workflow);
const props = schema?.properties as Record<
string,
{ additionalProperties?: { format?: string } }
>;
expect(props.roles?.additionalProperties?.format).toBe("cas_ref");
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 3: validate() — correct payloads pass for all 11 schema types
// ─────────────────────────────────────────────────────────────────────────────
describe("validate – valid payloads", () => {
const HASH = "AAAAAAAAAAAAA";
test("agent payload is valid", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const h = await store.put(agent, {
package: "gpt-4o",
version: "2024-11",
config: { temperature: 0.7 },
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("role-schema payload is valid (any object)", async () => {
const store = createMemoryStore();
const { roleSchema } = await registerWorkflowSchemas(store);
const h = await store.put(roleSchema, {
type: "object",
properties: { answer: { type: "string" } },
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("role payload is valid", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const h = await store.put(role, {
name: "analyst",
description: "Analyses data",
systemPrompt: "You are an analyst.",
extractPrompt: "Extract the findings.",
schema: HASH,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("workflow payload is valid", async () => {
const store = createMemoryStore();
const { workflow } = await registerWorkflowSchemas(store);
const h = await store.put(workflow, {
name: "research",
description: "Research workflow",
roles: { analyst: HASH },
moderator: [{ from: "analyst", to: "analyst", when: null }],
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-start payload is valid (null parentThread)", async () => {
const store = createMemoryStore();
const { threadStart } = await registerWorkflowSchemas(store);
const h = await store.put(threadStart, {
workflow: HASH,
input: "hello",
depth: 0,
parentThread: null,
agents: { main: HASH },
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-start payload is valid (non-null parentThread)", async () => {
const store = createMemoryStore();
const { threadStart } = await registerWorkflowSchemas(store);
const h = await store.put(threadStart, {
workflow: HASH,
input: "nested",
depth: 1,
parentThread: HASH,
agents: {},
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-step payload is valid (null previous)", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const h = await store.put(threadStep, {
role: "analyst",
meta: { attempt: 1 },
content: HASH,
react: HASH,
start: HASH,
previous: null,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-step payload is valid (non-null previous)", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const h = await store.put(threadStep, {
role: "analyst",
meta: {},
content: HASH,
react: HASH,
start: HASH,
previous: HASH,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-end payload is valid", async () => {
const store = createMemoryStore();
const { threadEnd } = await registerWorkflowSchemas(store);
const h = await store.put(threadEnd, {
returnCode: 0,
summary: "Done",
start: HASH,
lastStep: HASH,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("content payload is valid", async () => {
const store = createMemoryStore();
const { content } = await registerWorkflowSchemas(store);
const h = await store.put(content, { text: "Hello, world!" });
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-session payload is valid (empty turns)", async () => {
const store = createMemoryStore();
const { reactSession } = await registerWorkflowSchemas(store);
const h = await store.put(reactSession, {
agent: HASH,
role: "analyst",
turns: [],
totalTokens: 0,
durationMs: 42,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-session payload is valid (multiple turns)", async () => {
const store = createMemoryStore();
const { reactSession } = await registerWorkflowSchemas(store);
const h = await store.put(reactSession, {
agent: HASH,
role: "analyst",
turns: [HASH, HASH],
totalTokens: 300,
durationMs: 1500,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-turn payload is valid", async () => {
const store = createMemoryStore();
const { reactTurn } = await registerWorkflowSchemas(store);
const h = await store.put(reactTurn, {
input: HASH,
output: HASH,
toolCalls: [HASH],
tokens: { input: 100, output: 50 },
latencyMs: 800,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-tool-call payload is valid", async () => {
const store = createMemoryStore();
const { reactToolCall } = await registerWorkflowSchemas(store);
const h = await store.put(reactToolCall, {
name: "search",
arguments: HASH,
result: HASH,
durationMs: 200,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 4: validate() — invalid payloads fail for representative types
// ─────────────────────────────────────────────────────────────────────────────
describe("validate – invalid payloads", () => {
test("agent: missing required field fails", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const h = await store.put(agent, { package: "gpt-4o", version: "1" });
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("agent: wrong type for config fails", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const h = await store.put(agent, {
package: "gpt-4o",
version: "1",
config: "not-an-object",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("role: missing systemPrompt fails", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const h = await store.put(role, {
name: "analyst",
description: "d",
extractPrompt: "e",
schema: "AAAAAAAAAAAAA",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("thread-start: missing depth fails", async () => {
const store = createMemoryStore();
const { threadStart } = await registerWorkflowSchemas(store);
const h = await store.put(threadStart, {
workflow: "AAAAAAAAAAAAA",
input: "hi",
parentThread: null,
agents: {},
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("thread-end: returnCode as string fails", async () => {
const store = createMemoryStore();
const { threadEnd } = await registerWorkflowSchemas(store);
const h = await store.put(threadEnd, {
returnCode: "ok",
summary: "Done",
start: "AAAAAAAAAAAAA",
lastStep: "AAAAAAAAAAAAA",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("content: missing text fails", async () => {
const store = createMemoryStore();
const { content } = await registerWorkflowSchemas(store);
const h = await store.put(content, {});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("react-turn: tokens.input as string fails", async () => {
const store = createMemoryStore();
const { reactTurn } = await registerWorkflowSchemas(store);
const h = await store.put(reactTurn, {
input: "AAAAAAAAAAAAA",
output: "AAAAAAAAAAAAA",
toolCalls: [],
tokens: { input: "many", output: 50 },
latencyMs: 100,
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("react-tool-call: missing durationMs fails", async () => {
const store = createMemoryStore();
const { reactToolCall } = await registerWorkflowSchemas(store);
const h = await store.put(reactToolCall, {
name: "tool",
arguments: "AAAAAAAAAAAAA",
result: "AAAAAAAAAAAAA",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 5: refs() — extracts direct cas_ref fields from node payloads
// ─────────────────────────────────────────────────────────────────────────────
describe("refs – cas_ref extraction", () => {
const HASH_A = "AAAAAAAAAAAAA";
const HASH_B = "BBBBBBBBBBBBB";
test("content node has no cas_ref fields → empty array", async () => {
const store = createMemoryStore();
const { content } = await registerWorkflowSchemas(store);
const h = await store.put(content, { text: "hello" });
const node = store.get(h) as CasNode;
expect(refs(store, node)).toEqual([]);
});
test("role node: refs() returns the schema cas_ref", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const h = await store.put(role, {
name: "r",
description: "d",
systemPrompt: "s",
extractPrompt: "e",
schema: HASH_A,
});
const node = store.get(h) as CasNode;
expect(refs(store, node)).toContain(HASH_A);
});
test("thread-end: refs() returns start and lastStep", async () => {
const store = createMemoryStore();
const { threadEnd } = await registerWorkflowSchemas(store);
const h = await store.put(threadEnd, {
returnCode: 0,
summary: "done",
start: HASH_A,
lastStep: HASH_B,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_A);
expect(result).toContain(HASH_B);
expect(result).toHaveLength(2);
});
test("react-tool-call: refs() returns arguments and result", async () => {
const store = createMemoryStore();
const { reactToolCall } = await registerWorkflowSchemas(store);
const h = await store.put(reactToolCall, {
name: "search",
arguments: HASH_A,
result: HASH_B,
durationMs: 100,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_A);
expect(result).toContain(HASH_B);
expect(result).toHaveLength(2);
});
test("thread-step: refs() returns content, react, and start (previous null is skipped)", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const h = await store.put(threadStep, {
role: "r",
meta: {},
content: HASH_A,
react: HASH_B,
start: HASH_A,
previous: null,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_A);
expect(result).toContain(HASH_B);
});
test("thread-step: refs() includes previous when non-null", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const HASH_C = "CCCCCCCCCCCCC";
const h = await store.put(threadStep, {
role: "r",
meta: {},
content: HASH_A,
react: HASH_B,
start: HASH_A,
previous: HASH_C,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_C);
});
test("react-session: refs() returns the agent cas_ref", async () => {
const store = createMemoryStore();
const { reactSession } = await registerWorkflowSchemas(store);
const h = await store.put(reactSession, {
agent: HASH_A,
role: "r",
turns: [],
totalTokens: 0,
durationMs: 0,
});
const node = store.get(h) as CasNode;
expect(refs(store, node)).toContain(HASH_A);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 6: walk() — BFS traversal through linked workflow nodes
// ─────────────────────────────────────────────────────────────────────────────
describe("walk – cross-schema traversal", () => {
test("walk visits content node linked from thread-end", async () => {
const store = createMemoryStore();
const { threadEnd, content } = await registerWorkflowSchemas(store);
const contentHash = await store.put(content, { text: "summary text" });
const endHash = await store.put(threadEnd, {
returnCode: 0,
summary: "done",
start: contentHash,
lastStep: contentHash,
});
const visited = new Set<string>();
walk(store, endHash, (h) => visited.add(h));
expect(visited.has(endHash)).toBe(true);
expect(visited.has(contentHash)).toBe(true);
});
test("walk through role → (schema stored in store)", async () => {
const store = createMemoryStore();
const { role, roleSchema } = await registerWorkflowSchemas(store);
const schemaDocHash = await store.put(roleSchema, {
type: "object",
properties: { answer: { type: "string" } },
});
const roleHash = await store.put(role, {
name: "analyst",
description: "d",
systemPrompt: "s",
extractPrompt: "e",
schema: schemaDocHash,
});
const visited = new Set<string>();
walk(store, roleHash, (h) => visited.add(h));
expect(visited.has(roleHash)).toBe(true);
expect(visited.has(schemaDocHash)).toBe(true);
});
test("walk handles diamond: two thread-end nodes sharing the same start", async () => {
const store = createMemoryStore();
const { threadEnd, content } = await registerWorkflowSchemas(store);
const sharedStart = await store.put(content, { text: "start" });
const step1 = await store.put(content, { text: "step1" });
const step2 = await store.put(content, { text: "step2" });
const end1 = await store.put(threadEnd, {
returnCode: 0,
summary: "path A",
start: sharedStart,
lastStep: step1,
});
const end2 = await store.put(threadEnd, {
returnCode: 1,
summary: "path B",
start: sharedStart,
lastStep: step2,
});
// Use react-turn as the root linking both ends via input/output
const { reactTurn } = await registerWorkflowSchemas(store);
const turnHash = await store.put(reactTurn, {
input: end1,
output: end2,
toolCalls: [],
tokens: { input: 10, output: 5 },
latencyMs: 50,
});
const visited = new Set<string>();
walk(store, turnHash, (h) => visited.add(h));
expect(visited.has(turnHash)).toBe(true);
expect(visited.has(end1)).toBe(true);
expect(visited.has(end2)).toBe(true);
// sharedStart is reached from both end1 and end2, but visited only once
expect(visited.has(sharedStart)).toBe(true);
expect(visited.has(step1)).toBe(true);
expect(visited.has(step2)).toBe(true);
});
test("walk visits react-tool-call linked from react-turn", async () => {
const store = createMemoryStore();
const { reactTurn, reactToolCall, content } =
await registerWorkflowSchemas(store);
const argsHash = await store.put(content, { text: '{"q":"test"}' });
const resultHash = await store.put(content, { text: '{"r":"ok"}' });
const toolCallHash = await store.put(reactToolCall, {
name: "search",
arguments: argsHash,
result: resultHash,
durationMs: 120,
});
const inputHash = await store.put(content, { text: "input" });
const outputHash = await store.put(content, { text: "output" });
const turnHash = await store.put(reactTurn, {
input: inputHash,
output: outputHash,
toolCalls: [],
tokens: { input: 80, output: 40 },
latencyMs: 600,
});
const visited = new Set<string>();
walk(store, turnHash, (h) => visited.add(h));
expect(visited.has(turnHash)).toBe(true);
expect(visited.has(inputHash)).toBe(true);
expect(visited.has(outputHash)).toBe(true);
// toolCallHash is not in the turn's cas_ref fields (toolCalls array), only linked manually
expect(visited.has(toolCallHash)).toBe(false);
// walk from toolCallHash to verify it reaches args and result
const tcVisited = new Set<string>();
walk(store, toolCallHash, (h) => tcVisited.add(h));
expect(tcVisited.has(toolCallHash)).toBe(true);
expect(tcVisited.has(argsHash)).toBe(true);
expect(tcVisited.has(resultHash)).toBe(true);
});
});
-19
View File
@@ -1,19 +0,0 @@
export {
registerWorkflowSchemas,
type WorkflowSchemaHashes,
} from "./schemas.js";
export type {
AgentPayload,
ContentPayload,
ReactSessionPayload,
ReactToolCallPayload,
ReactTurnPayload,
ReactTurnTokens,
RolePayload,
RoleSchemaPayload,
ThreadEndPayload,
ThreadStartPayload,
ThreadStepPayload,
WorkflowPayload,
WorkflowTransition,
} from "./types.js";
-236
View File
@@ -1,236 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { type JSONSchema, putSchema } from "@uncaged/json-cas";
// ── Definition layer ──────────────────────────────────────────────────────────
const AGENT: JSONSchema = {
type: "object",
required: ["package", "version", "config"],
properties: {
package: { type: "string" },
version: { type: "string" },
config: { type: "object" },
},
additionalProperties: false,
};
/** role-schema nodes hold raw JSON Schema documents, so any object is valid. */
const ROLE_SCHEMA: JSONSchema = {
type: "object",
};
const ROLE: JSONSchema = {
type: "object",
required: ["name", "description", "systemPrompt", "extractPrompt", "schema"],
properties: {
name: { type: "string" },
description: { type: "string" },
systemPrompt: { type: "string" },
extractPrompt: { type: "string" },
schema: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
const WORKFLOW: JSONSchema = {
type: "object",
required: ["name", "description", "roles", "moderator"],
properties: {
name: { type: "string" },
description: { type: "string" },
roles: {
type: "object",
additionalProperties: { type: "string", format: "cas_ref" },
},
moderator: {
type: "array",
items: {
type: "object",
required: ["from", "to", "when"],
properties: {
from: { type: "string" },
to: { type: "string" },
when: { anyOf: [{ type: "string" }, { type: "null" }] },
},
additionalProperties: false,
},
},
},
additionalProperties: false,
};
// ── Execution layer ───────────────────────────────────────────────────────────
const THREAD_START: JSONSchema = {
type: "object",
required: ["workflow", "input", "depth", "parentThread", "agents"],
properties: {
workflow: { type: "string", format: "cas_ref" },
input: { type: "string" },
depth: { type: "number" },
parentThread: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
agents: {
type: "object",
additionalProperties: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
const THREAD_STEP: JSONSchema = {
type: "object",
required: ["role", "meta", "content", "react", "start", "previous"],
properties: {
role: { type: "string" },
meta: { type: "object" },
content: { type: "string", format: "cas_ref" },
react: { type: "string", format: "cas_ref" },
start: { type: "string", format: "cas_ref" },
previous: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
additionalProperties: false,
};
const THREAD_END: JSONSchema = {
type: "object",
required: ["returnCode", "summary", "start", "lastStep"],
properties: {
returnCode: { type: "number" },
summary: { type: "string" },
start: { type: "string", format: "cas_ref" },
lastStep: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
const CONTENT: JSONSchema = {
type: "object",
required: ["text"],
properties: {
text: { type: "string" },
},
additionalProperties: false,
};
// ── React layer ───────────────────────────────────────────────────────────────
const REACT_SESSION: JSONSchema = {
type: "object",
required: ["agent", "role", "turns", "totalTokens", "durationMs"],
properties: {
agent: { type: "string", format: "cas_ref" },
role: { type: "string" },
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
totalTokens: { type: "number" },
durationMs: { type: "number" },
},
additionalProperties: false,
};
const REACT_TURN: JSONSchema = {
type: "object",
required: ["input", "output", "toolCalls", "tokens", "latencyMs"],
properties: {
input: { type: "string", format: "cas_ref" },
output: { type: "string", format: "cas_ref" },
toolCalls: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
tokens: {
type: "object",
required: ["input", "output"],
properties: {
input: { type: "number" },
output: { type: "number" },
},
additionalProperties: false,
},
latencyMs: { type: "number" },
},
additionalProperties: false,
};
const REACT_TOOL_CALL: JSONSchema = {
type: "object",
required: ["name", "arguments", "result", "durationMs"],
properties: {
name: { type: "string" },
arguments: { type: "string", format: "cas_ref" },
result: { type: "string", format: "cas_ref" },
durationMs: { type: "number" },
},
additionalProperties: false,
};
// ── Registry ──────────────────────────────────────────────────────────────────
export type WorkflowSchemaHashes = {
agent: Hash;
roleSchema: Hash;
role: Hash;
workflow: Hash;
threadStart: Hash;
threadStep: Hash;
threadEnd: Hash;
content: Hash;
reactSession: Hash;
reactTurn: Hash;
reactToolCall: Hash;
};
/**
* Register all 11 workflow schemas into the given store.
* Returns a map from camelCase schema name to its CAS type hash.
* Idempotent: safe to call multiple times on the same store.
*/
export async function registerWorkflowSchemas(
store: Store,
): Promise<WorkflowSchemaHashes> {
const [
agent,
roleSchema,
role,
workflow,
threadStart,
threadStep,
threadEnd,
content,
reactSession,
reactTurn,
reactToolCall,
] = await Promise.all([
putSchema(store, AGENT),
putSchema(store, ROLE_SCHEMA),
putSchema(store, ROLE),
putSchema(store, WORKFLOW),
putSchema(store, THREAD_START),
putSchema(store, THREAD_STEP),
putSchema(store, THREAD_END),
putSchema(store, CONTENT),
putSchema(store, REACT_SESSION),
putSchema(store, REACT_TURN),
putSchema(store, REACT_TOOL_CALL),
]);
return {
agent,
roleSchema,
role,
workflow,
threadStart,
threadStep,
threadEnd,
content,
reactSession,
reactTurn,
reactToolCall,
};
}
-111
View File
@@ -1,111 +0,0 @@
import type { Hash } from "@uncaged/json-cas";
// ── Definition layer ──────────────────────────────────────────────────────────
export type AgentPayload = {
package: string;
version: string;
config: Record<string, unknown>;
};
/** A JSON Schema document stored as-is. */
export type RoleSchemaPayload = Record<string, unknown>;
export type RolePayload = {
name: string;
description: string;
systemPrompt: string;
extractPrompt: string;
/** cas_ref → role-schema */
schema: Hash;
};
export type WorkflowTransition = {
from: string;
to: string;
when: string | null;
};
export type WorkflowPayload = {
name: string;
description: string;
/** cas_ref → role */
roles: Record<string, Hash>;
moderator: WorkflowTransition[];
};
// ── Execution layer ───────────────────────────────────────────────────────────
export type ThreadStartPayload = {
/** cas_ref → workflow */
workflow: Hash;
input: string;
depth: number;
/** cas_ref → thread-start | null */
parentThread: Hash | null;
/** cas_ref → agent */
agents: Record<string, Hash>;
};
export type ThreadStepPayload = {
role: string;
meta: Record<string, unknown>;
/** cas_ref → content */
content: Hash;
/** cas_ref → react-session */
react: Hash;
/** cas_ref → thread-start */
start: Hash;
/** cas_ref → thread-step | null */
previous: Hash | null;
};
export type ThreadEndPayload = {
returnCode: number;
summary: string;
/** cas_ref → thread-start */
start: Hash;
/** cas_ref → thread-step */
lastStep: Hash;
};
export type ContentPayload = {
text: string;
};
// ── React layer ───────────────────────────────────────────────────────────────
export type ReactSessionPayload = {
/** cas_ref → agent */
agent: Hash;
role: string;
/** cas_ref → react-turn */
turns: Hash[];
totalTokens: number;
durationMs: number;
};
export type ReactTurnTokens = {
input: number;
output: number;
};
export type ReactTurnPayload = {
/** cas_ref → content */
input: Hash;
/** cas_ref → content */
output: Hash;
/** cas_ref → react-tool-call */
toolCalls: Hash[];
tokens: ReactTurnTokens;
latencyMs: number;
};
export type ReactToolCallPayload = {
name: string;
/** cas_ref → content (arguments) */
arguments: Hash;
/** cas_ref → content (result) */
result: Hash;
durationMs: number;
};
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
+25
View File
@@ -0,0 +1,25 @@
# @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
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
## 0.2.0
### Minor Changes
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
## 0.1.3
+159
View File
@@ -0,0 +1,159 @@
# @uncaged/json-cas
Core CAS engine — hashing, schema, store, verify, bootstrap.
## Overview
`@uncaged/json-cas` is the foundation of the json-cas monorepo. It defines content-addressed nodes (`CasNode`), the `Store` interface, XXH64-based hashing with deterministic CBOR, JSON Schema registration and validation (including `cas_ref` links between nodes), bootstrap seeding, and integrity verification.
Other packages build on this layer: `json-cas-fs` provides persistence, and `cli-json-cas` exposes store operations on the command line.
**Dependencies:** `ajv`, `cborg`, `xxhash-wasm`
## Installation
```bash
bun add @uncaged/json-cas
```
## API
All symbols below are exported from `src/index.ts`.
### Types
```typescript
/** 13-character uppercase Crockford Base32 (XXH64) */
type Hash = string;
type CasNode<T = unknown> = {
type: Hash;
payload: T;
timestamp: number; // Unix epoch ms
};
type Store = {
put(typeHash: Hash, payload: unknown): Promise<Hash>;
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
listByType(typeHash: Hash): Hash[];
};
type JSONSchema = Record<string, unknown>;
type BootstrapCapableStore = Store & {
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash>;
};
```
### Hashing
```typescript
function computeHash(typeHash: Hash, payload: unknown): Promise<Hash>;
function computeSelfHash(payload: unknown): Promise<Hash>;
function cborEncode(value: unknown): Uint8Array;
```
`computeHash``XXH64(utf8(typeHash) ++ CBOR(payload))` for normal nodes.
`computeSelfHash``XXH64(CBOR(payload))` for bootstrap nodes where `type === hash`.
### Bootstrap
```typescript
const BOOTSTRAP_STORE: unique symbol;
async function bootstrap(store: Store): Promise<Hash>;
```
Writes the meta-schema seed node (idempotent). Requires a `BootstrapCapableStore` (e.g. from `createMemoryStore()`).
### Schema
```typescript
class SchemaValidationError extends Error;
async function putSchema(store: Store, jsonSchema: JSONSchema): Promise<Hash>;
function getSchema(store: Store, typeHash: Hash): JSONSchema | null;
function validate(store: Store, node: CasNode): boolean;
function refs(store: Store, node: CasNode): Hash[];
function walk(
store: Store,
rootHash: Hash,
visitor: (hash: Hash, node: CasNode) => void,
): void;
```
- `putSchema` — stores a schema typed by the meta-schema; returned hash is the `typeHash` for conforming payloads.
- `refs` — collects all `format: "cas_ref"` values in the payload per schema shape.
- `walk` — BFS from `rootHash`, following `cas_ref` edges; cycles are visited once.
### Store
```typescript
function createMemoryStore(): BootstrapCapableStore;
```
In-memory `Store` with type indexing, suitable for tests and ephemeral use.
### Verify
```typescript
async function verify(hash: Hash, node: CasNode): Promise<boolean>;
```
Recomputes hash from `node` and compares to `hash` (self-referencing vs normal rules).
### Example
```typescript
import {
bootstrap,
createMemoryStore,
putSchema,
refs,
validate,
walk,
} from "@uncaged/json-cas";
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const personType = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
friend: { type: "string", format: "cas_ref" },
},
required: ["name"],
additionalProperties: false,
});
const aliceHash = await store.put(personType, { name: "Alice" });
const bobHash = await store.put(personType, {
name: "Bob",
friend: aliceHash,
});
const bob = store.get(bobHash)!;
console.log(validate(store, bob)); // true
console.log(refs(store, bob)); // [aliceHash]
walk(store, bobHash, (h) => console.log(h)); // bobHash, aliceHash
```
## Internal Structure
| File | Purpose |
|------|---------|
| `types.ts` | `Hash`, `CasNode`, `Store` |
| `hash.ts` | `computeHash`, `computeSelfHash` |
| `cbor.ts` | Deterministic CBOR encoding |
| `bootstrap-capable.ts` | `BOOTSTRAP_STORE` symbol and capability check |
| `bootstrap.ts` | Meta-schema seed and `bootstrap()` |
| `store.ts` | `createMemoryStore()` |
| `mem-store.ts` | Alternate in-memory store (tests only; not exported) |
| `schema.ts` | Schema put/get/validate, `refs`, `walk` |
| `verify.ts` | Node integrity verification |
| `index.ts` | Public exports |
Tests live in `src/*.test.ts` and `tests/`.
+13 -4
View File
@@ -1,13 +1,22 @@
{
"name": "@uncaged/json-cas",
"version": "0.1.1",
"version": "0.5.3",
"type": "module",
"main": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"test": "bun test"
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"ajv": "^8.20.0",
@@ -0,0 +1,16 @@
import type { Hash, Store } from "./types.js";
/** @internal Store implementations attach this for bootstrap() only. */
export const BOOTSTRAP_STORE = Symbol.for("@uncaged/json-cas/bootstrap-store");
export type BootstrapCapableStore = Store & {
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash>;
};
export function isBootstrapCapableStore(
store: Store,
): store is BootstrapCapableStore {
return (
typeof (store as BootstrapCapableStore)[BOOTSTRAP_STORE] === "function"
);
}
+61 -12
View File
@@ -1,20 +1,66 @@
import {
BOOTSTRAP_STORE,
isBootstrapCapableStore,
} from "./bootstrap-capable.js";
import type { Hash, Store } from "./types.js";
const JSON_SCHEMA_TYPES = [
"string",
"number",
"integer",
"boolean",
"object",
"array",
"null",
] as const;
/**
* The meta-schema seed payload: describes the structure of every CAS node.
* This is the root type from which all other type nodes derive.
* Self-describing JSON Schema meta-schema for the supported schema subset.
* Stored as the bootstrap node's payload; its hash equals the node's type field.
*/
const BOOTSTRAP_PAYLOAD = {
description: "json-cas meta-schema seed",
hashAlgorithm: "xxh64",
hashEncoding: "crockford-base32-13",
nodeSchema: {
payload: "any",
timestamp: "number",
type: "Hash",
type: "object",
additionalProperties: false,
description: "json-cas JSON Schema meta-schema",
properties: {
type: {
anyOf: [
{ type: "string", enum: [...JSON_SCHEMA_TYPES] },
{
type: "array",
items: { type: "string", enum: [...JSON_SCHEMA_TYPES] },
},
],
},
properties: {
type: "object",
additionalProperties: { type: "object", additionalProperties: false },
},
required: {
type: "array",
items: { type: "string" },
},
additionalProperties: {
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" },
enum: { type: "array" },
const: {},
description: { type: "string" },
},
payloadEncoding: "cbor-rfc8949-deterministic",
version: "1",
} as const;
/**
@@ -23,5 +69,8 @@ const BOOTSTRAP_PAYLOAD = {
* Idempotent: calling bootstrap multiple times returns the same hash.
*/
export async function bootstrap(store: Store): Promise<Hash> {
return store.put(null, BOOTSTRAP_PAYLOAD);
if (!isBootstrapCapableStore(store)) {
throw new Error("Store does not support bootstrap");
}
return store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
}
+74 -9
View File
@@ -4,7 +4,7 @@ import { bootstrap } from "./bootstrap.js";
import { cborEncode } from "./cbor.js";
import { computeHash, computeSelfHash } from "./hash.js";
import { createMemoryStore } from "./store.js";
import type { CasNode } from "./types.js";
import type { CasNode, Store } from "./types.js";
import { verify } from "./verify.js";
// ──────────────────────────────────────────────────────────────────────────────
@@ -97,7 +97,18 @@ describe("createMemoryStore – put and get", () => {
const h1 = await store.put(typeHash, { n: 42 });
const h2 = await store.put(typeHash, { n: 42 });
expect(h1).toBe(h2);
expect(store.list()).toHaveLength(1);
expect(store.listByType(typeHash)).toHaveLength(1);
});
test("put does not create self-referencing nodes", async () => {
const store = createMemoryStore();
const payload = { name: "type-descriptor" };
const typeHash = await computeSelfHash(payload);
const hash = await store.put(typeHash, payload);
const node = store.get(hash);
expect(node?.type).toBe(typeHash);
expect(node?.type).not.toBe(hash);
});
test("timestamp is preserved on second put (idempotency)", async () => {
@@ -116,9 +127,9 @@ describe("createMemoryStore – put and get", () => {
});
// ──────────────────────────────────────────────────────────────────────────────
// Step 4: store.has() and store.list()
// Step 4: store.has()
// ──────────────────────────────────────────────────────────────────────────────
describe("createMemoryStore – has and list", () => {
describe("createMemoryStore – has", () => {
test("has returns false before put, true after", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "t" });
@@ -129,7 +140,7 @@ describe("createMemoryStore – has and list", () => {
expect(store.has(hash)).toBe(true);
});
test("list returns all stored hashes", async () => {
test("listByType returns all stored hashes for a type", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "t" });
@@ -137,16 +148,58 @@ describe("createMemoryStore – has and list", () => {
const h2 = await store.put(typeHash, { a: 2 });
const h3 = await store.put(typeHash, { a: 3 });
const all = store.list();
const all = store.listByType(typeHash);
expect(all).toHaveLength(3);
expect(all).toContain(h1);
expect(all).toContain(h2);
expect(all).toContain(h3);
});
test("list returns empty array on fresh store", () => {
test("listByType returns empty array on fresh store", () => {
const store = createMemoryStore();
expect(store.list()).toEqual([]);
expect(store.listByType("0000000000000")).toEqual([]);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Step 4b: store.listByType()
// ──────────────────────────────────────────────────────────────────────────────
describe("createMemoryStore – listByType", () => {
test("returns empty array for unknown type", () => {
const store = createMemoryStore();
expect(store.listByType("0000000000000")).toEqual([]);
});
test("returns all hashes for the given type", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "t" });
const otherType = await computeSelfHash({ name: "other" });
const h1 = await store.put(typeHash, { a: 1 });
const h2 = await store.put(typeHash, { a: 2 });
await store.put(otherType, { b: 1 });
const byType = store.listByType(typeHash);
expect(byType).toHaveLength(2);
expect(byType).toContain(h1);
expect(byType).toContain(h2);
});
test("idempotent put does not duplicate in listByType", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "t" });
const h1 = await store.put(typeHash, { n: 1 });
await store.put(typeHash, { n: 1 });
expect(store.listByType(typeHash)).toEqual([h1]);
});
test("bootstrap node is listed under its self type", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
expect(store.listByType(hash)).toEqual([hash]);
});
});
@@ -191,6 +244,18 @@ describe("verify", () => {
// Step 6: bootstrap()
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap", () => {
test("throws when store lacks internal bootstrap path", async () => {
const store: Store = {
put: async () => "0000000000000",
get: () => null,
has: () => false,
listByType: () => [],
};
await expect(bootstrap(store)).rejects.toThrow(
"Store does not support bootstrap",
);
});
test("returns a valid 13-char hash", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
@@ -229,6 +294,6 @@ describe("bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toBe(h2);
expect(store.list()).toHaveLength(1);
expect(store.listByType(h1)).toHaveLength(1);
});
});
+19 -1
View File
@@ -1,8 +1,26 @@
export { bootstrap } from "./bootstrap.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";
export { getSchema, putSchema, refs, validate, walk } from "./schema.js";
export {
getSchema,
putSchema,
refs,
SchemaValidationError,
validate,
walk,
} from "./schema.js";
export { createMemoryStore } from "./store.js";
export type { CasNode, Hash, Store } from "./types.js";
export type { Variable, VariableId } from "./variable.js";
export {
CasNodeNotFoundError,
createVariableStore,
InvalidScopeError,
SchemaMismatchError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
export { verify } from "./verify.js";
+33
View File
@@ -0,0 +1,33 @@
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";
/** In-memory store wrapper used by schema validation tests. */
export class MemStore implements BootstrapCapableStore {
readonly #inner: BootstrapCapableStore;
constructor() {
this.#inner = createMemoryStore();
}
put(typeHash: Hash, payload: unknown): Promise<Hash> {
return this.#inner.put(typeHash, payload);
}
get(hash: Hash): CasNode | null {
return this.#inner.get(hash);
}
has(hash: Hash): boolean {
return this.#inner.has(hash);
}
listByType(typeHash: Hash): Hash[] {
return this.#inner.listByType(typeHash);
}
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash> {
return this.#inner[BOOTSTRAP_STORE](payload);
}
}
+128 -1
View File
@@ -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";
@@ -12,9 +15,125 @@ import type { CasNode, Hash, Store } from "./types.js";
export type JSONSchema = Record<string, unknown>;
export class SchemaValidationError extends Error {
override readonly name = "SchemaValidationError";
}
const ajv = new Ajv();
ajv.addFormat("cas_ref", /^[0-9A-HJKMNP-TV-Z]{13}$/);
const ALLOWED_SCHEMA_KEYS = new Set([
"type",
"properties",
"required",
"additionalProperties",
"anyOf",
"oneOf",
"items",
"format",
"title",
"enum",
"const",
"description",
]);
const JSON_SCHEMA_TYPES = new Set([
"string",
"number",
"integer",
"boolean",
"object",
"array",
"null",
]);
function isValidTypeValue(type: unknown): boolean {
if (typeof type === "string") {
return JSON_SCHEMA_TYPES.has(type);
}
if (Array.isArray(type)) {
if (type.length === 0) return false;
return type.every(
(entry) => typeof entry === "string" && JSON_SCHEMA_TYPES.has(entry),
);
}
return false;
}
function isValidSchema(value: unknown): boolean {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const schema = value as JSONSchema;
for (const key of Object.keys(schema)) {
if (!ALLOWED_SCHEMA_KEYS.has(key)) return false;
}
if ("type" in schema && !isValidTypeValue(schema.type)) return false;
if ("properties" in schema) {
const properties = schema.properties;
if (
properties === null ||
typeof properties !== "object" ||
Array.isArray(properties)
) {
return false;
}
for (const nested of Object.values(properties as Record<string, unknown>)) {
if (!isValidSchema(nested)) return false;
}
}
if ("required" in schema) {
if (!Array.isArray(schema.required)) return false;
for (const entry of schema.required) {
if (typeof entry !== "string") return false;
}
}
if ("additionalProperties" in schema) {
const additionalProperties = schema.additionalProperties;
if (typeof additionalProperties === "boolean") {
// allowed
} else if (!isValidSchema(additionalProperties)) {
return false;
}
}
if ("anyOf" in schema) {
if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) return false;
for (const entry of schema.anyOf) {
if (!isValidSchema(entry)) return false;
}
}
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;
if ("description" in schema && typeof schema.description !== "string") {
return false;
}
if ("enum" in schema) {
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
}
return true;
}
function isMetaSchemaNode(store: Store, node: CasNode): boolean {
const schema = getSchema(store, node.type);
return schema !== null && schema === node.payload;
}
/**
* Store a JSON Schema as a CAS node typed by the meta-schema hash.
* The returned hash becomes the typeHash for nodes that conform to this schema.
@@ -24,6 +143,11 @@ export async function putSchema(
jsonSchema: JSONSchema,
): Promise<Hash> {
const metaHash = await bootstrap(store);
if (!isValidSchema(jsonSchema)) {
throw new SchemaValidationError(
"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema",
);
}
return store.put(metaHash, jsonSchema);
}
@@ -44,6 +168,9 @@ export function getSchema(store: Store, typeHash: Hash): JSONSchema | null {
export function validate(store: Store, node: CasNode): boolean {
const schema = getSchema(store, node.type);
if (schema === null) return false;
if (isMetaSchemaNode(store, node)) {
return isValidSchema(node.payload);
}
return ajv.validate(
schema as Parameters<typeof ajv.validate>[0],
node.payload,
+37 -12
View File
@@ -1,19 +1,39 @@
import {
BOOTSTRAP_STORE,
type BootstrapCapableStore,
} from "./bootstrap-capable.js";
import { computeHash, computeSelfHash } from "./hash.js";
import type { CasNode, Hash, Store } from "./types.js";
import type { CasNode, Hash } from "./types.js";
export function createMemoryStore(): Store {
export function createMemoryStore(): BootstrapCapableStore {
const data = new Map<Hash, CasNode>();
const byType = new Map<Hash, Set<Hash>>();
return {
async put(typeHash: Hash | null, payload: unknown): Promise<Hash> {
const hash =
typeHash === null
? await computeSelfHash(payload)
: await computeHash(typeHash, payload);
function indexHash(type: Hash, hash: Hash): void {
let set = byType.get(type);
if (!set) {
set = new Set();
byType.set(type, set);
}
set.add(hash);
}
async function putSelfReferencing(payload: unknown): Promise<Hash> {
const hash = await computeSelfHash(payload);
if (!data.has(hash)) {
data.set(hash, { type: hash, payload, timestamp: Date.now() });
indexHash(hash, hash);
}
return hash;
}
const store: BootstrapCapableStore = {
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
const hash = await computeHash(typeHash, payload);
if (!data.has(hash)) {
const type = typeHash === null ? hash : typeHash;
data.set(hash, { type, payload, timestamp: Date.now() });
data.set(hash, { type: typeHash, payload, timestamp: Date.now() });
indexHash(typeHash, hash);
}
return hash;
@@ -27,8 +47,13 @@ export function createMemoryStore(): Store {
return data.has(hash);
},
list(): Hash[] {
return [...data.keys()];
listByType(typeHash: Hash): Hash[] {
const set = byType.get(typeHash);
return set ? [...set] : [];
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
return store;
}
+3 -3
View File
@@ -17,11 +17,11 @@ export type CasNode<T = unknown> = {
/**
* Content-addressable store interface.
* put(null, payload) creates a self-referencing (bootstrap) node.
* Self-referencing nodes are created only via bootstrap().
*/
export type Store = {
put(typeHash: Hash | null, payload: unknown): Promise<Hash>;
put(typeHash: Hash, payload: unknown): Promise<Hash>;
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
list(): Hash[];
listByType(typeHash: Hash): Hash[];
};
@@ -0,0 +1,403 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import {
CasNodeNotFoundError,
InvalidScopeError,
SchemaMismatchError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
describe("VariableStore", () => {
let store: Store;
let varStore: VariableStore;
let dbPath: string;
let schemaA: string;
let schemaB: string;
let hashA: string;
let hashB: string;
let hashC: string;
beforeEach(async () => {
// Create a temporary database
dbPath = join(tmpdir(), `test-variables-${Date.now()}.db`);
// Create a CAS store with test data
store = createMemoryStore();
// Create two different schemas
schemaA = await store.put("BOOTSTRAPHASH", {
type: "object",
properties: { name: { type: "string" } },
});
schemaB = await store.put("BOOTSTRAPHASH", {
type: "object",
properties: { count: { type: "number" } },
});
// Create CAS nodes with different schemas
hashA = await store.put(schemaA, { name: "hello" });
hashB = await store.put(schemaA, { name: "world" });
hashC = await store.put(schemaB, { count: 42 });
// Create variable store
varStore = new VariableStore(dbPath, store);
});
afterEach(() => {
varStore.close();
try {
unlinkSync(dbPath);
} catch {
// Ignore cleanup errors
}
});
describe("Test Group 1: Variable Creation", () => {
test("1.1: Create variable with valid scope", () => {
const variable = varStore.create("uwf/thread/", hashA);
expect(variable.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaA);
expect(variable.created).toBeGreaterThan(Date.now() - 5000);
expect(variable.created).toBeLessThanOrEqual(Date.now());
expect(variable.updated).toBe(variable.created);
// Verify persistence
const retrieved = varStore.get(variable.id);
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe(variable.id);
expect(retrieved?.scope).toBe(variable.scope);
expect(retrieved?.value).toBe(variable.value);
});
test("1.2: Create variable fails with scope not ending in /", () => {
expect(() => varStore.create("uwf/thread", hashA)).toThrow(
InvalidScopeError,
);
expect(() => varStore.create("uwf/thread", hashA)).toThrow(
"scope must end with /",
);
});
test("1.3: Create variable fails with non-existent CAS node", () => {
const fakeHash = "FAKEHASH00000";
expect(() => varStore.create("uwf/", fakeHash)).toThrow(
CasNodeNotFoundError,
);
expect(() => varStore.create("uwf/", fakeHash)).toThrow(
`CAS node not found: ${fakeHash}`,
);
});
});
describe("Test Group 2: Variable Retrieval", () => {
test("2.1: Get existing variable", () => {
const created = varStore.create("uwf/thread/", hashA);
const retrieved = varStore.get(created.id);
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe(created.id);
expect(retrieved?.scope).toBe("uwf/thread/");
expect(retrieved?.value).toBe(hashA);
expect(retrieved?.schema).toBe(schemaA);
expect(retrieved?.created).toBe(created.created);
expect(retrieved?.updated).toBe(created.updated);
});
test("2.2: Get non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
const result = varStore.get(fakeId);
expect(result).toBeNull();
});
});
describe("Test Group 3: Variable Update (Schema Consistent)", () => {
test("3.1: Update variable with matching schema", async () => {
const created = varStore.create("uwf/thread/", hashA);
const t1 = created.created;
// Wait a bit to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = varStore.update(created.id, hashB);
expect(updated.id).toBe(created.id);
expect(updated.scope).toBe("uwf/thread/");
expect(updated.value).toBe(hashB);
expect(updated.schema).toBe(schemaA);
expect(updated.created).toBe(t1);
expect(updated.updated).toBeGreaterThan(t1);
expect(updated.updated).toBeGreaterThan(Date.now() - 5000);
expect(updated.updated).toBeLessThanOrEqual(Date.now());
// Verify persistence
const retrieved = varStore.get(created.id);
expect(retrieved?.value).toBe(hashB);
expect(retrieved?.updated).toBe(updated.updated);
});
test("3.2: Update variable to same value is idempotent", () => {
const created = varStore.create("uwf/thread/", hashA);
const updated = varStore.update(created.id, hashA);
expect(updated.value).toBe(hashA);
expect(updated.schema).toBe(schemaA);
// Updated timestamp may change, this is implementation-defined
});
});
describe("Test Group 4: Variable Update (Schema Mismatch)", () => {
test("4.1: Update variable fails with schema mismatch", () => {
const created = varStore.create("uwf/thread/", hashA);
expect(() => varStore.update(created.id, hashC)).toThrow(
SchemaMismatchError,
);
const error = (() => {
try {
varStore.update(created.id, hashC);
return null;
} catch (e) {
return e as SchemaMismatchError;
}
})();
expect(error).not.toBeNull();
expect(error?.expected).toBe(schemaA);
expect(error?.actual).toBe(schemaB);
expect(error?.message.toLowerCase()).toContain("schema mismatch");
// Verify variable is unchanged
const retrieved = varStore.get(created.id);
expect(retrieved?.value).toBe(hashA);
});
test("4.2: Update variable fails with non-existent CAS node", () => {
const created = varStore.create("uwf/thread/", hashA);
const fakeHash = "FAKEHASH00000";
expect(() => varStore.update(created.id, fakeHash)).toThrow(
CasNodeNotFoundError,
);
});
test("4.3: Update non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
expect(() => varStore.update(fakeId, hashA)).toThrow(
VariableNotFoundError,
);
expect(() => varStore.update(fakeId, hashA)).toThrow(
`Variable not found: ${fakeId}`,
);
});
});
describe("Test Group 5: Variable Deletion", () => {
test("5.1: Delete existing variable", () => {
const created = varStore.create("uwf/thread/", hashA);
const deleted = varStore.delete(created.id);
expect(deleted.id).toBe(created.id);
expect(deleted.scope).toBe(created.scope);
expect(deleted.value).toBe(created.value);
expect(deleted.schema).toBe(created.schema);
// Verify it's removed from database
const retrieved = varStore.get(created.id);
expect(retrieved).toBeNull();
});
test("5.2: Get deleted variable", () => {
const created = varStore.create("uwf/thread/", hashA);
varStore.delete(created.id);
const retrieved = varStore.get(created.id);
expect(retrieved).toBeNull();
});
test("5.3: Delete non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
expect(() => varStore.delete(fakeId)).toThrow(VariableNotFoundError);
expect(() => varStore.delete(fakeId)).toThrow(
`Variable not found: ${fakeId}`,
);
});
});
describe("Test Group 6: Variable Listing", () => {
test("6.1: list() returns all variables with matching scope prefix", async () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("uwf/thread/", hashB);
const var3 = varStore.create("uwf/agent/", hashA);
varStore.create("app/config/", hashA);
// Wait a bit to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
const results = varStore.list({ scope: "uwf/" });
expect(results).toHaveLength(3);
expect(results.every((v) => v.scope.startsWith("uwf/"))).toBe(true);
// Verify ordering by created timestamp
expect(results[0]?.id).toBe(var1.id);
expect(results[1]?.id).toBe(var2.id);
expect(results[2]?.id).toBe(var3.id);
});
test("6.2: list() returns empty array when no matches", () => {
varStore.create("uwf/thread/", hashA);
const results = varStore.list({ scope: "nonexistent/" });
expect(results).toHaveLength(0);
expect(Array.isArray(results)).toBe(true);
});
test("6.3: list() returns all variables when scope is empty string", () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("app/config/", hashB);
const var3 = varStore.create("test/", hashC);
const results = varStore.list({ scope: "" });
expect(results).toHaveLength(3);
expect(results.map((v) => v.id)).toContain(var1.id);
expect(results.map((v) => v.id)).toContain(var2.id);
expect(results.map((v) => v.id)).toContain(var3.id);
});
test("6.4: list() validates scope format (must end with /)", () => {
varStore.create("uwf/thread/", hashA);
expect(() => varStore.list({ scope: "uwf" })).toThrow(InvalidScopeError);
expect(() => varStore.list({ scope: "uwf" })).toThrow(
"scope must end with /",
);
});
test("6.5: list() returns exact scope match and sub-scopes", () => {
varStore.create("uwf/thread/", hashA);
varStore.create("uwf/thread/active/", hashB);
const results = varStore.list({ scope: "uwf/thread/" });
expect(results).toHaveLength(2);
expect(results[0]?.scope).toBe("uwf/thread/");
expect(results[1]?.scope).toBe("uwf/thread/active/");
});
test("6.6: list() result ordering is deterministic", async () => {
// Create 5 variables with the same scope prefix
const var1 = varStore.create("test/", hashA);
await new Promise((resolve) => setTimeout(resolve, 2));
const var2 = varStore.create("test/", hashB);
await new Promise((resolve) => setTimeout(resolve, 2));
const var3 = varStore.create("test/", hashA);
await new Promise((resolve) => setTimeout(resolve, 2));
const var4 = varStore.create("test/", hashB);
await new Promise((resolve) => setTimeout(resolve, 2));
const var5 = varStore.create("test/", hashA);
// Call list multiple times
const results1 = varStore.list({ scope: "test/" });
const results2 = varStore.list({ scope: "test/" });
const results3 = varStore.list({ scope: "test/" });
// All results should be identical
expect(results1.map((v) => v.id)).toEqual(results2.map((v) => v.id));
expect(results2.map((v) => v.id)).toEqual(results3.map((v) => v.id));
// Verify ordering by created timestamp (oldest first)
expect(results1[0]?.id).toBe(var1.id);
expect(results1[1]?.id).toBe(var2.id);
expect(results1[2]?.id).toBe(var3.id);
expect(results1[3]?.id).toBe(var4.id);
expect(results1[4]?.id).toBe(var5.id);
});
});
describe("Test Group 7: Integration Tests", () => {
test("7.1: Full lifecycle workflow", async () => {
// Create variable
const var1 = varStore.create("uwf/thread/", hashA);
expect(var1.value).toBe(hashA);
// Get variable
const retrieved1 = varStore.get(var1.id);
expect(retrieved1?.value).toBe(hashA);
// Wait to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
// Update variable
const updated = varStore.update(var1.id, hashB);
expect(updated.value).toBe(hashB);
expect(updated.updated).toBeGreaterThan(var1.created);
// Get updated variable
const retrieved2 = varStore.get(var1.id);
expect(retrieved2?.value).toBe(hashB);
// Delete variable
const deleted = varStore.delete(var1.id);
expect(deleted.value).toBe(hashB);
// Verify deletion
const retrieved3 = varStore.get(var1.id);
expect(retrieved3).toBeNull();
});
test("7.2: Multiple variables with same scope", () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("uwf/thread/", hashB);
// Verify independence
expect(var1.id).not.toBe(var2.id);
const retrieved1 = varStore.get(var1.id);
const retrieved2 = varStore.get(var2.id);
expect(retrieved1?.value).toBe(hashA);
expect(retrieved2?.value).toBe(hashB);
// Update var1, verify var2 is unaffected
varStore.update(var1.id, hashB);
const retrieved2After = varStore.get(var2.id);
expect(retrieved2After?.value).toBe(hashB);
expect(retrieved2After?.updated).toBe(var2.updated);
// Delete var1, verify var2 still exists
varStore.delete(var1.id);
const retrieved2Final = varStore.get(var2.id);
expect(retrieved2Final).not.toBeNull();
expect(retrieved2Final?.value).toBe(hashB);
});
test("7.3: Variables with hierarchical scopes", () => {
const var1 = varStore.create("uwf/", hashA);
const var2 = varStore.create("uwf/thread/", hashA);
const var3 = varStore.create("uwf/workflow/", hashA);
expect(var1.scope).toBe("uwf/");
expect(var2.scope).toBe("uwf/thread/");
expect(var3.scope).toBe("uwf/workflow/");
// All should exist independently
expect(varStore.get(var1.id)).not.toBeNull();
expect(varStore.get(var2.id)).not.toBeNull();
expect(varStore.get(var3.id)).not.toBeNull();
});
});
});
+256
View File
@@ -0,0 +1,256 @@
import { Database } from "bun:sqlite";
import { ulid } from "ulidx";
import type { Store } from "./types.js";
import type { Variable, VariableId } from "./variable.js";
/**
* Custom error types for variable operations
*/
export class VariableNotFoundError extends Error {
constructor(id: VariableId) {
super(`Variable not found: ${id}`);
this.name = "VariableNotFoundError";
}
}
export class SchemaMismatchError extends Error {
constructor(
public expected: string,
public actual: string,
) {
super(`Schema mismatch: expected ${expected}, got ${actual}`);
this.name = "SchemaMismatchError";
}
}
export class InvalidScopeError extends Error {
constructor(scope: string) {
super(`Invalid scope: scope must end with / (got: ${scope})`);
this.name = "InvalidScopeError";
}
}
export class CasNodeNotFoundError extends Error {
constructor(hash: string) {
super(`CAS node not found: ${hash}`);
this.name = "CasNodeNotFoundError";
}
}
/**
* Variable store with SQLite backend
*/
export class VariableStore {
private db: Database;
constructor(
dbPath: string,
private casStore: Store,
) {
this.db = new Database(dbPath, { create: true });
this.initDb();
}
private initDb(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS variables (
id TEXT PRIMARY KEY,
scope TEXT NOT NULL,
value TEXT NOT NULL,
schema TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_var_scope ON variables(scope);
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
`);
}
/**
* Validate that scope ends with /
*/
private validateScope(scope: string): void {
if (!scope.endsWith("/")) {
throw new InvalidScopeError(scope);
}
}
/**
* Extract schema hash from CAS node
*/
private extractSchema(hash: string): string {
const node = this.casStore.get(hash);
if (node === null) {
throw new CasNodeNotFoundError(hash);
}
return node.type;
}
/**
* Create a new variable
*/
create(scope: string, value: string): Variable {
this.validateScope(scope);
const schema = this.extractSchema(value);
const id = ulid();
const now = Date.now();
const stmt = this.db.prepare(`
INSERT INTO variables (id, scope, value, schema, created, updated)
VALUES (?, ?, ?, ?, ?, ?)
`);
stmt.run(id, scope, value, schema, now, now);
return {
id,
scope,
value,
schema,
created: now,
updated: now,
};
}
/**
* Get a variable by ID
*/
get(id: VariableId): Variable | null {
const stmt = this.db.prepare(`
SELECT id, scope, value, schema, created, updated
FROM variables
WHERE id = ?
`);
const row = stmt.get(id) as
| {
id: string;
scope: string;
value: string;
schema: string;
created: number;
updated: number;
}
| undefined
| null;
if (row === undefined || row === null) {
return null;
}
return {
id: row.id,
scope: row.scope,
value: row.value,
schema: row.schema,
created: row.created,
updated: row.updated,
};
}
/**
* Update a variable's value (with schema validation)
*/
update(id: VariableId, value: string): Variable {
const existing = this.get(id);
if (existing === null) {
throw new VariableNotFoundError(id);
}
const newSchema = this.extractSchema(value);
if (newSchema !== existing.schema) {
throw new SchemaMismatchError(existing.schema, newSchema);
}
const now = Date.now();
const stmt = this.db.prepare(`
UPDATE variables
SET value = ?, updated = ?
WHERE id = ?
`);
stmt.run(value, now, id);
return {
...existing,
value,
updated: now,
};
}
/**
* Delete a variable
*/
delete(id: VariableId): Variable {
const existing = this.get(id);
if (existing === null) {
throw new VariableNotFoundError(id);
}
const stmt = this.db.prepare(`
DELETE FROM variables WHERE id = ?
`);
stmt.run(id);
return existing;
}
/**
* List variables matching a scope prefix
*/
list(options?: { scope?: string }): Variable[] {
const scope = options?.scope ?? "";
// Validate scope format (must end with / if non-empty)
if (scope !== "" && !scope.endsWith("/")) {
throw new InvalidScopeError(scope);
}
const stmt = this.db.prepare(`
SELECT id, scope, value, schema, created, updated
FROM variables
WHERE scope LIKE ? || '%'
ORDER BY created ASC
`);
const rows = stmt.all(scope) as Array<{
id: string;
scope: string;
value: string;
schema: string;
created: number;
updated: number;
}>;
return rows.map((row) => ({
id: row.id,
scope: row.scope,
value: row.value,
schema: row.schema,
created: row.created,
updated: row.updated,
}));
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
}
/**
* Create a variable store
*/
export function createVariableStore(
dbPath: string,
casStore: Store,
): VariableStore {
return new VariableStore(dbPath, casStore);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Hash } from "./types.js";
/**
* ULID identifier (26-character Crockford Base32)
*/
export type VariableId = string;
/**
* Variable: mutable binding to an immutable CAS node
*/
export type Variable = {
id: VariableId;
scope: string; // hierarchical path, must end with /
value: Hash; // CAS node hash
schema: Hash; // extracted from value's CAS node.type
created: number; // epoch ms
updated: number; // epoch ms
};
@@ -0,0 +1,727 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "../src/bootstrap.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", () => {
test("1.1: Meta-schema is a valid JSON Schema", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
expect(typeof metaNode?.payload).toBe("object");
expect(metaNode?.payload).toHaveProperty("type");
});
test("1.2: Meta-schema self-validates", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
expect(validate(store, metaNode as CasNode)).toBe(true);
});
test("1.3: Meta-schema defines all supported keywords", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
const properties =
(metaSchema?.properties as Record<string, unknown>) || {};
// Check that all supported keywords are defined
expect(properties).toHaveProperty("type");
expect(properties).toHaveProperty("properties");
expect(properties).toHaveProperty("required");
expect(properties).toHaveProperty("additionalProperties");
expect(properties).toHaveProperty("anyOf");
expect(properties).toHaveProperty("items");
expect(properties).toHaveProperty("format");
expect(properties).toHaveProperty("title");
expect(properties).toHaveProperty("enum");
expect(properties).toHaveProperty("const");
expect(properties).toHaveProperty("description");
});
test("1.4: Meta-schema does not include unsupported keywords", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
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("not");
});
test("1.5: Meta-schema node type equals its own hash", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
expect(metaNode?.type).toBe(metaHash);
});
});
describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
test("2.1: Accept minimal valid schema (empty object)", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {});
expect(hash).toBeTruthy();
});
test("2.2: Accept schema with type constraint", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, { type: "string" });
expect(hash).toBeTruthy();
});
test("2.3: Accept schema with properties", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "object",
properties: { name: { type: "string" } },
});
expect(hash).toBeTruthy();
});
test("2.4: Accept schema with required fields", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
});
expect(hash).toBeTruthy();
});
test("2.5: Accept schema with additionalProperties = false", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "object",
additionalProperties: false,
});
expect(hash).toBeTruthy();
});
test("2.6: Accept schema with additionalProperties = schema", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "object",
additionalProperties: { type: "string" },
});
expect(hash).toBeTruthy();
});
test("2.7: Accept schema with anyOf", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
anyOf: [{ type: "string" }, { type: "null" }],
});
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);
const hash = await putSchema(store, {
type: "array",
items: { type: "number" },
});
expect(hash).toBeTruthy();
});
test("2.9: Accept schema with format constraint", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "string",
format: "cas_ref",
});
expect(hash).toBeTruthy();
});
test("2.10: Accept schema with enum", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "string",
enum: ["red", "green", "blue"],
});
expect(hash).toBeTruthy();
});
test("2.11: Accept schema with const", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, { const: "FIXED_VALUE" });
expect(hash).toBeTruthy();
});
test("2.12: Accept schema with title and description", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "string",
title: "User Name",
description: "The user's full name",
});
expect(hash).toBeTruthy();
});
test("2.13: Accept complex nested schema", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "object",
required: ["type", "payload"],
properties: {
type: { type: "string", format: "cas_ref" },
payload: {
anyOf: [{ type: "object" }, { type: "null" }],
},
refs: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
});
expect(hash).toBeTruthy();
});
});
describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
test("3.1: Reject schema with invalid type value", async () => {
const store = new MemStore();
await bootstrap(store);
expect(async () => await putSchema(store, { type: "garbage" })).toThrow();
});
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 unknown as JSONSchema),
).toThrow();
});
test("3.3: Reject schema with properties not an object", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
type: "object",
properties: "not-an-object",
} as unknown as JSONSchema),
).toThrow();
});
test("3.4: Reject schema with required not an array", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
type: "object",
required: "name",
} as unknown as JSONSchema),
).toThrow();
});
test("3.5: Reject schema with required containing non-strings", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
type: "object",
required: ["name", 123, true],
} as unknown as JSONSchema),
).toThrow();
});
test("3.6: Reject schema with additionalProperties as string", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
type: "object",
additionalProperties: "yes",
} as unknown as JSONSchema),
).toThrow();
});
test("3.7: Reject schema with anyOf not an array", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
anyOf: { type: "string" },
} as unknown as JSONSchema),
).toThrow();
});
test("3.8: Reject schema with empty anyOf array", async () => {
const store = new MemStore();
await bootstrap(store);
expect(async () => await putSchema(store, { anyOf: [] })).toThrow();
});
test("3.9: Reject schema with items not an object", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
type: "array",
items: "string",
} as unknown as JSONSchema),
).toThrow();
});
test("3.10: Reject schema with format not a string", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
type: "string",
format: 123,
} as unknown as JSONSchema),
).toThrow();
});
test("3.11: Reject schema with enum not an array", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
type: "string",
enum: "red",
} as unknown as JSONSchema),
).toThrow();
});
test("3.12: Reject schema with empty enum array", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () => await putSchema(store, { type: "string", enum: [] }),
).toThrow();
});
test("3.13: Reject schema with title not a string", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
type: "string",
title: 123,
} as unknown as JSONSchema),
).toThrow();
});
test("3.14: Reject schema with description not a string", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
type: "string",
description: ["not a string"],
} as unknown as JSONSchema),
).toThrow();
});
test("3.15: Reject schema with unsupported $ref keyword", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
$ref: "#/definitions/user",
} as unknown as JSONSchema),
).toThrow();
});
test("3.16: Reject completely invalid data (non-object)", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, "not-a-schema" as unknown as JSONSchema),
).toThrow();
});
test("3.17: Reject nested invalid schema in properties", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () =>
await putSchema(store, {
type: "object",
properties: {
name: { type: "invalid-type" },
},
} as unknown as JSONSchema),
).toThrow();
});
});
describe("Test Suite 4: Error Messages and Debugging", () => {
test("4.1: Error includes schema validation details", async () => {
const store = new MemStore();
await bootstrap(store);
try {
await putSchema(store, { type: 123 } as unknown as JSONSchema);
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeInstanceOf(SchemaValidationError);
expect((error as Error).message).toContain("Invalid schema");
}
});
test("4.2: Error distinguishes schema validation from data validation", async () => {
const store = new MemStore();
await bootstrap(store);
try {
await putSchema(store, { type: "invalid-type" } as unknown as JSONSchema);
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeInstanceOf(SchemaValidationError);
expect((error as Error).message.toLowerCase()).toContain("schema");
}
});
});
describe("Test Suite 5: Backward Compatibility and Migration", () => {
test("5.1: Bootstrap hash changes (breaking change)", async () => {
// This is a documentation test - the old hash was different
const store = new MemStore();
const newMetaHash = await bootstrap(store);
// The new hash should be different from the old system metadata hash
// We just verify it's a valid hash format
expect(newMetaHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("5.2: Existing tests compatibility", async () => {
// This test ensures our changes don't break existing valid schema usage
const store = new MemStore();
await bootstrap(store);
// This is the kind of schema that existed before
const schemaHash = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
},
});
expect(schemaHash).toBeTruthy();
});
test("5.3: Data nodes with valid schemas still validate", async () => {
const store = new MemStore();
await bootstrap(store);
const schemaHash = await putSchema(store, {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
},
});
const dataNode = store.get(await store.put(schemaHash, { name: "test" }));
expect(dataNode).not.toBeNull();
expect(validate(store, dataNode as CasNode)).toBe(true);
});
test("5.4: Invalid data still fails validation", async () => {
const store = new MemStore();
await bootstrap(store);
const schemaHash = await putSchema(store, {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
},
});
const dataNode = store.get(
await store.put(schemaHash, { name: 123 }), // wrong type
);
expect(dataNode).not.toBeNull();
expect(validate(store, dataNode as CasNode)).toBe(false);
});
});
describe("Test Suite 6: Integration with Existing Functionality", () => {
test("6.1: getSchema works with validated schemas", async () => {
const store = new MemStore();
await bootstrap(store);
const originalSchema = { type: "string", title: "Test" };
const schemaHash = await putSchema(store, originalSchema);
const retrieved = getSchema(store, schemaHash);
expect(retrieved).toEqual(originalSchema);
});
test("6.2: validate() works with schemas validated by meta-schema", async () => {
const store = new MemStore();
await bootstrap(store);
const schemaHash = await putSchema(store, { type: "number" });
const validNode = store.get(await store.put(schemaHash, 42));
const invalidNode = store.get(await store.put(schemaHash, "not a number"));
expect(validate(store, validNode as CasNode)).toBe(true);
expect(validate(store, invalidNode as CasNode)).toBe(false);
});
test("6.3: refs() works with validated schemas containing cas_ref", async () => {
const store = new MemStore();
await bootstrap(store);
const schemaHash = await putSchema(store, {
type: "object",
properties: {
ref: { type: "string", format: "cas_ref" },
},
});
const refHash = "0000000000001";
const dataNode = store.get(await store.put(schemaHash, { ref: refHash }));
const extractedRefs = refs(store, dataNode as CasNode);
expect(extractedRefs).toContain(refHash);
});
test("6.4: walk() works with graphs using validated schemas", async () => {
const store = new MemStore();
await bootstrap(store);
const schemaHash = await putSchema(store, {
type: "object",
properties: {
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
const node2Hash = await store.put(schemaHash, { next: null });
const node1Hash = await store.put(schemaHash, { next: node2Hash });
const visited: string[] = [];
walk(store, node1Hash, (hash) => visited.push(hash));
expect(visited).toContain(node1Hash);
expect(visited).toContain(node2Hash);
});
test("6.5: Idempotency preserved for putSchema", async () => {
const store = new MemStore();
await bootstrap(store);
const schema = { type: "string", title: "Test" };
const hash1 = await putSchema(store, schema);
const hash2 = await putSchema(store, schema);
expect(hash1).toBe(hash2);
});
});
describe("Test Suite 7: Meta-Schema Content Validation", () => {
test("7.1: Meta-schema allows recursive schema definitions", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
// The meta-schema should have properties that can contain schemas
const properties =
(metaSchema?.properties as Record<string, unknown>) || {};
expect(properties).toHaveProperty("properties");
});
test("7.2: Meta-schema restricts additionalProperties", async () => {
const store = new MemStore();
await bootstrap(store);
// Schema with unknown keyword should be rejected if meta-schema is strict
try {
await putSchema(store, {
type: "string",
unknownKeyword: "value",
} as unknown as JSONSchema);
// If we get here, meta-schema allows additional properties
// This is acceptable behavior
} catch (error) {
// If it throws, meta-schema is strict about additionalProperties
expect(error).toBeInstanceOf(SchemaValidationError);
}
});
test("7.3: Meta-schema validates type as string OR array", async () => {
const store = new MemStore();
await bootstrap(store);
// Single string type
const hash1 = await putSchema(store, { type: "string" });
expect(hash1).toBeTruthy();
// Array of types
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 unknown as JSONSchema),
).toThrow();
});
});
describe("Test Suite 8: Performance and Edge Cases", () => {
test("8.1: Validation performance is acceptable", async () => {
const store = new MemStore();
await bootstrap(store);
const complexSchema = {
type: "object",
properties: {
level1: {
type: "object",
properties: {
level2: {
type: "object",
properties: {
level3: { type: "string" },
},
},
},
},
},
};
const start = performance.now();
for (let i = 0; i < 100; i++) {
await putSchema(store, complexSchema);
}
const duration = performance.now() - start;
// Should complete in reasonable time (< 100ms for 100 validations)
expect(duration).toBeLessThan(1000);
});
test("8.2: Large schemas are handled correctly", async () => {
const store = new MemStore();
await bootstrap(store);
const largeSchema: Record<string, unknown> = {
type: "object",
properties: {},
};
// Create a schema with 100 properties
const props = largeSchema.properties as Record<string, unknown>;
for (let i = 0; i < 100; i++) {
props[`prop${i}`] = { type: "string" };
}
const hash = await putSchema(store, largeSchema);
expect(hash).toBeTruthy();
});
test("8.3: Deeply nested schemas validate correctly", async () => {
const store = new MemStore();
await bootstrap(store);
// Build a 5-level deep schema
let schema: Record<string, unknown> = { type: "string" };
for (let i = 0; i < 5; i++) {
schema = {
type: "object",
properties: { nested: schema },
};
}
const hash = await putSchema(store, schema);
expect(hash).toBeTruthy();
});
test("8.4: Circular-like schemas don't cause infinite loops", async () => {
const store = new MemStore();
await bootstrap(store);
// Schema where additionalProperties has same structure as parent
const schema = {
type: "object",
properties: {
value: { type: "string" },
},
additionalProperties: {
type: "object",
properties: {
value: { type: "string" },
},
},
};
const hash = await putSchema(store, schema);
expect(hash).toBeTruthy();
});
});
+2 -1
View File
@@ -4,5 +4,6 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+4
View File
@@ -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,