Compare commits

...

38 Commits

Author SHA1 Message Date
xiaoju 07e08e3b38 feat: Add CLI integration for LiquidJS template rendering
Integrates LiquidJS template rendering into the CLI render command.
When a template is registered for a node's schema type via the variable
system (@ucas/template/text/<schema-hash>), the CLI will use the template
for rendering. Otherwise, it falls back to YAML output.

Changes:
- Modified cmdRender in index.ts to use renderAsync with variable store
- Added Suite 6: CLI Integration with Templates (5 comprehensive tests)
- Fixed template file format: templates must be JSON-encoded strings
- Removed unused render import from index.ts
- Renamed unused globalDecay parameter in liquid-render.ts

Test coverage increased from 336 to 341 tests, all passing.

Fixes #40

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 06:05:44 +00:00
xiaoju e0af351991 fix: resolve TypeScript strict mode and dynamic import issues
Fixes reviewer feedback:
1. Fixed TypeScript strict mode error where ctx.engine was possibly null
   - Refactored to pass store/varStore/globalDecay directly to createLiquidEngine
   - Eliminated RenderContext type that caused circular dependency
   - Engine is now properly typed as Liquid (non-nullable)

2. Removed dynamic imports from production code
   - Changed render.ts to use static import of renderWithTemplate
   - Changed hasTemplate to use static import of putSchema
   - Complies with CLAUDE.md convention against dynamic imports

All tests pass (336 tests), build succeeds with no TypeScript errors,
lint checks pass with only 1 minor warning about unused parameter
(which is actually used in recursive calls).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 06:05:44 +00:00
xiaoju 72f85c9077 feat: implement LiquidJS template rendering integration
Integrates LiquidJS as the template engine for CAS node rendering with
custom {% render %} tag supporting recursive rendering with resolution
decay. Templates are discovered via variables under
@ucas/template/text/<type-hash>. When ucas render <hash> is invoked,
the system queries for a registered template; if found, uses LiquidJS;
otherwise falls back to Phase 3's default YAML renderer.

Key features:
- Custom {% render %} tag with recursive CAS node rendering
- Decay priority chain: template decay > CLI --decay > default 0.5
- Context variables: resolution, epsilon, hash, payload, type, timestamp
- Graceful fallback: No template → YAML rendering (Phase 3)
- Zero breaking changes: All Phase 3 tests still pass
- Template discovery via @ucas/template/text/<type-hash> variables

Implementation:
- New file: packages/json-cas/src/liquid-render.ts — LiquidJS integration
- Modified: packages/json-cas/src/render.ts — Template lookup + YAML fallback
- New file: packages/json-cas/src/liquid-render.test.ts — 32 comprehensive tests
- Dependency: liquidjs npm package
- CLI: No changes needed (transparent integration)

All tests pass (336 tests), build succeeds, lint checks pass.

Closes #40

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 06:05:44 +00:00
xiaoju cccfca3137 Merge pull request 'feat: implement template CLI subcommands (set/get/list/delete)' (#44) from fix/38-template-cli into main 2026-05-31 05:29:12 +00:00
xiaoju 5f2906908c feat: implement template CLI subcommands (set/get/list/delete)
Implement ucas template subcommands for managing template storage:
- template set <schema-hash> <file> | --inline <text>: Store template text in CAS
- template get <schema-hash>: Retrieve template as raw text
- template list: List all templates with preview
- template delete <schema-hash>: Delete template variable binding

Templates are stored as plain text under @string schema and bound to
variables using the naming pattern @ucas/template/text/<schema-hash>.

Fixes #38

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 05:06:47 +00:00
xiaoju 077eaa6f6d Merge pull request 'feat: implement render engine with resolution decay (#39)' (#43) from fix/39-render-rebase into main 2026-05-31 04:51:17 +00:00
xiaoju 7e23d911a4 feat: implement render engine with resolution decay (#39)
Implement Phase 3: render core engine with resolution-based decay and
default YAML rendering.

Core Features:
- Resolution decay model: child nodes receive resolution = parent × decay
- Epsilon threshold: nodes with resolution ≤ epsilon render as cas:<hash>
- Default YAML output format with 2-space indentation
- Cycle detection via visited set
- Floating-point tolerance for epsilon comparisons

Implementation:
- packages/json-cas/src/render.ts: Core render function
- packages/json-cas/src/render.test.ts: 38 comprehensive tests
- packages/cli-json-cas: ucas render command with --resolution, --decay, --epsilon flags
- CLI integration tests for render command

Tests: All 276 tests pass (38 new render tests, 3 CLI tests)
Build: Clean compilation with tsc
Lint: Passes biome check

Fixes #39

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 04:50:55 +00:00
xiaoju 301b05c212 Merge pull request 'feat: add built-in schema aliases with @ prefix support' (#42) from fix/37-builtin-schema-aliases into main 2026-05-31 04:45:20 +00:00
xiaoju 22fce0ac66 feat: add built-in schema aliases with @ prefix support
Implements Phase 1 of issue #37:
- Extended variable name validation to allow @ prefix (system-reserved)
- Registered 6 built-in schemas with @ aliases during bootstrap
  - @schema → meta-schema (self-referential)
  - @string → { type: "string" }
  - @number → { type: "number" }
  - @object → { type: "object" }
  - @array → { type: "array" }
  - @bool → { type: "boolean" }
- Bootstrap now returns Record<string, Hash> instead of Hash
- Added CLI @ alias resolution for all commands accepting type-hash
  - ucas schema get @string
  - ucas put @string <file>
  - ucas hash @string <file>
- Added comprehensive test coverage for all features
  - Variable name validation with @ prefix
  - Built-in schema registration
  - CLI alias resolution
  - Integration tests

Fixes #37

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 04:18:46 +00:00
xiaoju fddbb1549e feat: RFC-31 Phase 3 — CLI var 子命令重写
- var set <name> <hash> (upsert, replaces create+update)
- var get <name> --schema <hash> (schema required)
- var delete <name> [--schema <hash>] (optional schema)
- var list [prefix] (replaces --scope)
- var tag <name> --schema <hash> ...
- 41 CLI tests, all passing

Fixes #34
Ref #31
2026-05-30 15:44:19 +00:00
xiaoju 109aaab9b8 feat: RFC-31 Phase 3 — rewrite CLI var subcommands for composite key model
Migrate CLI var subcommands from ULID ID model to (name, schema) composite key model.

- Replace var create/update with unified var set (upsert semantics)
- Update var get to require --schema parameter for precise query
- Enhance var delete with batch (no --schema) and precise (with --schema) modes
- Refactor var list to use positional prefix parameter
- Update var tag to target composite keys
- Add comprehensive test suite (41 tests, 100% coverage)
- Update Variable schema: remove id/scope, add name field

Fixes #34

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 14:29:33 +00:00
xiaoju 906a6dfd1c feat: RFC-31 Phase 1+2 — Variable model refactor with (name, schema) composite key
- Replace ULID id + scope with qualified name + schema composite PK
- Add set() upsert, remove() with optional schema, validateName()
- get(name, schema) with fixed return type (no polymorphic)
- Tags/labels adapted to composite foreign keys
- GC compatible

Fixes #32
Ref #31
2026-05-30 13:36:51 +00:00
xiaoju 5e7db0ef6b refactor: apply PR #33 Review Round 2 fixes
Addresses Review Round 2 feedback for variable model refactor:

1. Remove create() method - set() is now the unified entry point
2. Remove VariableDuplicateError class (only used by create())
3. Clean up dead code: Array.isArray(existing) checks in update()/remove()/tag()
4. Add tag/label conflict validation to set() update path
5. Migrate gc.test.ts from create() to set()

Changes:
- Delete create() method (lines 381-467) and VariableDuplicateError class
- Remove Array.isArray checks from 3 methods (always null, never array)
- Remove orphaned delete() JSDoc comment
- Add 3 new tests for set() update path tag/label conflict validation
- Replace 10 create() calls with set() in gc.test.ts

Test results: 193 pass (190 existing + 3 new)
Build: clean, Lint: clean

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 13:25:01 +00:00
xiaoju 31f84a7ab0 refactor: implement PR #33 review feedback - Variable API refinements
Closes #33

## Breaking Changes

### 1. get() signature - schema required
- **Before:** `get(name, schema?)` with polymorphic return `Variable | Variable[] | null`
- **After:** `get(name, schema)` with required schema, returns `Variable | null`
- **Migration:** Use `list({ exactName })` to query all schema variants

### 2. delete() method removed
- **Removed:** `delete(name, schema)` method
- **Use:** `remove(name, schema?)` as the sole deletion API

## New Features

### 3. list() enhanced with exactName parameter
- **Added:** `exactName` parameter for exact name matching
- **Use case:** Query all schema variants of an exact name
- **Example:** `list({ exactName: "config" })` returns all schemas for "config"
- **Validation:** `exactName` and `namePrefix` are mutually exclusive

### 4. Additional verification tests
- Added tests confirming set() schema extraction behavior
- Added comprehensive validateName() error message tests
- Verified detailed error messages for all validation violations

## Implementation Details

### Changes in variable-store.ts
- Simplified get() to single signature with required schema
- Removed deprecated delete() method
- Enhanced list() with exactName parameter and validation
- Updated remove() to use list({ exactName }) for multi-variant queries
- Fixed tag() method to remove redundant Array.isArray check

### Changes in tests
- Replaced get() without schema tests with new required-schema tests
- Added 8 comprehensive tests for list({ exactName }) functionality
- Added 5 validateName() error message verification tests
- Added 2 set() schema extraction verification tests
- Updated integration test to use list({ exactName }) instead of get(name)
- Updated gc.test.ts to use remove() instead of delete()

## Verification
-  190 tests pass (1 unrelated CLI test fails)
-  TypeScript build passes with no errors
-  Biome lint and format pass
-  All Variable model tests pass
-  GC integration tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 12:56:34 +00:00
xiaoju 793a5c619d feat: implement RFC #31 Phase 1 - variable model API improvements
Closes #33

## Changes

### 1. Enhanced Name Validation
- Added `validateName()` private method with comprehensive validation rules
- Updated `InvalidVariableNameError` to include specific `reason` field
- Validation rules:
  - Each segment must match [a-zA-Z0-9._-]+
  - Segments separated by /
  - No empty segments (e.g., a//b)
  - No leading/trailing slashes (e.g., /a or a/)
- Applied to all mutating operations: set(), create(), update(), tag()

### 2. New set() Upsert Method
- Implements upsert semantics: insert if not exists, update if exists
- Checks (name, schema) pair existence using extractSchema(value)
- On update: preserves created timestamp, updates value and updated timestamp
- Preserves existing tags/labels when called without options
- Replaces tags/labels when called with options
- Allows same name with different schemas

### 3. Optional Schema in get()
- Overloaded signature: get(name) and get(name, schema)
- get(name) without schema:
  - Returns null when no variables exist
  - Returns single Variable when one schema variant exists
  - Returns Variable[] when multiple schema variants exist
- get(name, schema) with schema:
  - Returns Variable | null for exact match
  - Includes complete tags and labels

### 4. Renamed delete() to remove() with Optional Schema
- Overloaded signature: remove(name) and remove(name, schema)
- remove(name) without schema:
  - Deletes all schema variants for the name
  - Returns Variable[] (all deleted variants)
  - Returns empty array [] when nothing found
- remove(name, schema) with schema:
  - Deletes specific (name, schema) variant
  - Returns single Variable
  - Throws VariableNotFoundError when not found
- Cascades deletion to tags and labels via foreign key constraints

### 5. Code Quality Improvements
- Fixed `any` type usage, replaced with `unknown` and proper type guards
- Fixed string concatenation to use template literals
- Updated all internal get() calls to handle new return types
- Applied strict null checks and array checks

### 6. Comprehensive Test Coverage
- 36 tests covering all new behaviors
- Test suites for:
  - set() upsert method (7 tests)
  - get() with optional schema (6 tests)
  - remove() with optional schema (6 tests)
  - Name validation (6 tests)
  - Integration workflows (2 tests)
  - Legacy methods (2 tests)
  - Database schema verification
  - List and tag operations
- All tests pass: bun test (151 pass, 0 fail)

## Verification
-  bun test - All 151 tests pass
-  bun run build - Clean TypeScript build
-  bunx biome check - No lint errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 11:11:00 +00:00
xiaoju b89e31f468 feat: refactor Variable model to use (name, schema) composite key
Implements RFC-31 Phase 1 - refactors the Variable model to use a composite
primary key of (name, schema) instead of the previous ULID id + scope approach.

Key changes:

1. **Type Model**:
   - Removed `id: VariableId` and `scope: string` fields
   - Added `name: string` as part of composite key with `schema: Hash`
   - Variables with same name but different schemas are now distinct entities

2. **Database Schema**:
   - Changed primary key from `id` to `(name, schema)`
   - Updated foreign keys in `variable_tags` and `variable_labels` tables
   - Replaced scope-based indexes with name-based indexes
   - Enabled foreign key constraints for proper cascade deletes

3. **CRUD Operations** - all methods updated to use `(name, schema)`:
   - `create(name, value, options)` - validates unique (name, schema)
   - `get(name, schema)` - retrieves by composite key
   - `update(name, schema, value)` - updates with schema validation
   - `delete(name, schema)` - deletes with cascade to tags/labels
   - `list({ namePrefix?, schema?, tags?, labels? })` - filters by name prefix and schema
   - `tag(name, schema, operations)` - manages tags/labels by composite key

4. **Error Types**:
   - New: `VariableDuplicateError` for duplicate `(name, schema)` pairs
   - New: `InvalidVariableNameError` for empty names
   - Removed: `InvalidScopeError` (no longer needed)
   - Updated: `VariableNotFoundError` to reference `(name, schema)`

5. **GC Adaptation**:
   - Garbage collection works correctly with refactored model
   - Preserves nodes referenced by variables across all schemas
   - Global collection across all variable names and schemas

6. **Tests**:
   - Added comprehensive test suite covering all new functionality
   - Database schema validation tests
   - CRUD operation tests with composite keys
   - Multi-schema scenarios (same name, different schemas)
   - Tag/label management tests
   - GC integration tests
   - End-to-end workflow tests

7. **Breaking Changes**:
   - This is a backward-incompatible change
   - CLI commands will need updates in a future phase (out of scope for Phase 1)
   - Data migration is out of scope for Phase 1

Fixes #32

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 10:26:30 +00:00
xiaoju b9131c728e Merge pull request 'feat: add ucas command alias' (#26) from fix/24-ucas-alias into main 2026-05-30 09:02:29 +00:00
xiaonuo cd338822f2 Merge pull request 'feat: RFC-20 Phase 3 — GC Integration' (#30) from fix/23-gc-integration into main 2026-05-30 08:30:50 +00:00
xiaoju 7242588dd9 feat: implement RFC-20 Phase 3 GC integration
Implements garbage collection (GC) with mark-and-sweep algorithm:
- Mark phase: recursively walks references from all variable values (global, not scoped)
- Sweep phase: deletes unmarked CAS nodes
- Schema preservation: schemas referenced by reachable nodes are preserved
- Bootstrap preservation: self-referencing meta-schema always preserved

New features:
- Core gc() function in packages/json-cas/src/gc.ts with GcStats interface
- Extended Store interface with listAll() and delete() methods
- CLI command: json-cas gc (outputs JSON stats)
- Comprehensive test suite with 16 test scenarios

Implements: #23

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 08:16:20 +00:00
xiaonuo c34a8b3c58 Merge pull request 'feat: implement RFC-20 Phase 2 tag/label + query system' (#29) from fix/22-tag-label-query into main 2026-05-30 07:57:23 +00:00
xingyue 08b143ea0b feat: add retrospect-workflow for self-improvement 2026-05-30 15:50:22 +08:00
xiaoju 1269de5b96 feat: implement RFC-20 Phase 2 tag/label + query system
Implements comprehensive tag/label functionality for variables:

## Core Features
- Tags: key-value pairs with same-key override semantics
- Labels: bare identifiers
- Deletion syntax: `:name` removes tag or label
- Mutual exclusion: tag keys and label names cannot coexist
- Unified `var tag` command for all tag/label operations

## Data Model
- Extended Variable type with tags/labels fields
- New variable_tags and variable_labels SQLite tables
- Foreign key constraints with CASCADE delete
- Proper indexes for efficient querying

## Query Capabilities
- Filter by scope (hierarchical prefix matching)
- Filter by tags (key:value pairs, AND logic)
- Filter by labels (bare names, AND logic)
- Combined filtering (scope + tags/labels)

## CLI Commands
- `json-cas var create --tag <tag>...` - initial tags/labels
- `json-cas var tag <id> <tag>...` - add/update/delete
- `json-cas var list --tag <tag>...` - query with filters

## Implementation Details
- TagLabelConflictError and InvalidTagFormatError types
- Atomic batch operations with rollback
- 46 comprehensive tests for tags/labels
- Backward compatible with Phase 1
- All 214 tests pass

Closes #22

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 07:38:06 +00:00
xiaonuo 263fe40146 Merge pull request 'chore: Phase 1 code style fixes and missing features' (#28) from fix/27-phase1-code-style-fixes into main 2026-05-30 06:51:34 +00:00
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
xingyue 1e8ccb8962 feat: add ucas command alias to cli-json-cas bin field
Fixes #24

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-05-30 14:06:23 +08: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
52 changed files with 9785 additions and 1299 deletions
+3
View File
@@ -0,0 +1,3 @@
# Sync README
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/
*.d.ts.map
*.tsbuildinfo
.worktrees/
+220
View File
@@ -0,0 +1,220 @@
name: "retrospect-workflow"
description: "Post-execution retrospective: analyze a completed thread, find inefficiencies, and improve the workflow definition."
roles:
analyst:
description: "Scans thread execution for anomalies and produces a findings report"
goal: "You are a workflow execution analyst. You review completed thread data to find inefficiencies, wasted effort, and procedure gaps."
capabilities:
- data-analysis
procedure: |
You receive a completed thread ID in your task prompt.
Phase 0 — Validation (must pass before any analysis):
1. Run `uwf step list <thread-id>` to get thread metadata including the workflow hash
2. Run `uwf workflow show <workflow-hash>` to get the workflow name
3. Verify the workflow exists locally: check `.workflows/<name>.yaml` in the current repo
- If NOT found: output $status=wrong_project with the workflow name. Do NOT proceed.
4. Compare the thread's workflow hash against the current registered version:
- Run `uwf workflow show <name>` to get the current hash
- If hashes differ: the thread ran on an older version. Note this — you will need to diff versions after analysis.
Phase 1 — Overview scan:
5. From the step list, compute a health signal for each step:
- Duration: flag if >2x the median of other steps
- Output tokens: flag if >2x the median
- Status flow: flag non-happy-path transitions (rejected, fix_code, fix_spec, hook_failed)
- Step count: flag if the same role appears more than expected (indicates loops)
6. If no anomalies found AND versions match: output $status=clean
7. If no anomalies found BUT versions differ:
- Diff the two workflow versions to check if any procedure changes are relevant
- If the current version already addresses potential concerns: output $status=clean with a note
- Otherwise: proceed to Phase 2
Phase 2 — Targeted deep-dive (only for flagged steps):
8. For each flagged step, run `uwf step show <hash>` to get the detail with turns
9. Analyze the turn sequence for:
- Repeated tool calls with the same or similar input (blind retries)
- Tool errors followed by no strategy change (same approach retried)
- Unnecessary exploration (reading files or running commands unrelated to the task)
- Hallucinated commands or flags (commands that don't exist or wrong syntax)
- Excessive turns before reaching the goal
10. For each finding, record:
- Which role and step hash
- What happened (specific turn indices and commands)
- Root cause hypothesis (procedure gap, missing pitfall, unclear instruction)
- Suggested fix (what to add/change in the procedure)
11. If versions differ: compare findings against the version diff.
Mark any finding that is already fixed in the current version as "resolved_in_current".
Only report findings that are NOT yet addressed.
Output a structured findings report. Set $status=clean if nothing actionable, $status=findings if unresolved issues exist, or $status=wrong_project if the workflow doesn't belong here.
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
frontmatter:
oneOf:
- properties:
$status: { const: "clean" }
summary: { type: string }
required: [$status, summary]
- properties:
$status: { const: "findings" }
report: { type: string }
targetWorkflow: { type: string }
required: [$status, report, targetWorkflow]
- properties:
$status: { const: "wrong_project" }
workflowName: { type: string }
required: [$status, workflowName]
proposer:
description: "Translates findings into concrete workflow edits"
goal: "You are a workflow improvement proposer. You read the analyst's findings and produce specific, minimal edits to the workflow YAML."
capabilities:
- planning
procedure: |
1. Read the analyst's findings report from your task prompt
2. Locate the target workflow YAML:
- Workflow definitions live in the WORKFLOW ENGINE repo (where `uwf` is developed), NOT in the repo that was analyzed.
- Find it via: `uwf workflow show <targetWorkflow> --format yaml` to read the current definition
- The physical file is `.workflows/<targetWorkflow>.yaml` in the workflow engine repo
- Use `git rev-parse --show-toplevel` in the current directory to find the workflow engine repo root
3. Read the current workflow YAML to understand existing procedures
4. For each finding, draft a minimal edit:
- Prefer adding a pitfall note or clarifying instruction over restructuring
- If a procedure step is ambiguous, make it explicit
- If a tool usage pattern is wrong, add a "Do NOT" or "IMPORTANT" note
- Keep edits surgical — don't rewrite procedures that work fine
5. Check if existing tests need updating (search for test files referencing the workflow)
6. Produce a change plan as CAS text node via `uwf cas put-text "<plan>"`
The plan should list each edit with:
- File path
- What to change (old text → new text, or addition)
- Why (linked to which finding)
- Any test updates needed
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
frontmatter:
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "no_action" }
reason: { type: string }
required: [$status, reason]
developer:
description: "Applies the proposed workflow edits"
goal: "You are a developer agent. You apply workflow YAML edits and update related tests."
capabilities:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The workflow definitions live in THIS repo (the workflow engine), not the repo that was analyzed.
Before starting any work, set up an isolated worktree:
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
2. `git fetch origin` to get latest refs
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
4. `cd .worktrees/retrospect/<short-slug> && bun install`
5. ALL subsequent work must happen inside the worktree directory.
Then apply changes:
6. Read the change plan from CAS: `uwf cas get <plan hash>`
7. Apply each edit from the plan to the workflow YAML
8. Update or add tests as specified in the plan
9. Run `bun run build` and `bun test` to verify
10. Run `bun run check` for lint
11. Commit with message: `improve: <workflow-name> — <brief summary>`
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: "Reviews the workflow edits for correctness"
goal: "You are a reviewer. You verify that workflow edits are minimal, correct, and actually address the findings."
capabilities:
- code-review
procedure: |
The worktree path is provided in your task prompt. cd into it first.
Review criteria:
1. Each edit must trace back to a specific finding — no drive-by changes
2. Edits should be minimal — don't rewrite working procedures
3. New pitfall notes or instructions must be clear and actionable
4. Tests must be updated if assertions changed
5. `bun run build` and `bun test` must pass
6. `bunx biome check` must pass
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
output: "Explain your decision. 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]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR."
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: `git commit -m "improve: <workflow> — <summary>"`
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 --title "..." --description "..."`
- IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files. cd to the repo root first.
- Do NOT pass `--repo` — let tea auto-detect from the main repo's git remote.
- PR description must include: What / Why / Findings / Changes sections
- 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: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
analyst:
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
wrong_project: { role: "$END", prompt: "Thread uses workflow '{{{workflowName}}}' which does not exist in this project. Run retrospect from the correct repo." }
proposer:
no_action: { role: "$END", prompt: "No actionable changes needed: {{{reason}}}." }
ready: { role: "developer", prompt: "Apply the change plan (CAS hash: {{{plan}}}) to the workflow definitions in this repo." }
developer:
done: { role: "reviewer", prompt: "Review workflow edits on branch {{{branch}}} at {{{worktree}}}." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in {{{worktree}}}." }
approved: { role: "committer", prompt: "Approved. 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 improved." }
+108 -134
View File
@@ -9,10 +9,10 @@ roles:
- planning
procedure: |
On first run (no previous steps):
1. Read the issue and all comments from Gitea using `tea issues <number>`
2. Read project conventions (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) to understand coding standards
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number>` (skip if you already commented), then output status=insufficient_info and terminate
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
On subsequent runs (bounced back by tester with fix_spec):
@@ -21,17 +21,19 @@ roles:
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when status=ready)
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
type: object
properties:
status:
type: string
enum: [ready, insufficient_info]
plan:
type: string
required: [status]
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
@@ -39,38 +41,41 @@ roles:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
Set up variables from the current working directory:
```
REPO_ROOT=$(git rev-parse --show-toplevel)
WORKTREE_BASE=$(dirname $REPO_ROOT)/$(basename $REPO_ROOT)-worktrees
```
The repo path and other details are provided in your task prompt.
Before starting any work, set up an isolated worktree:
1. `cd $REPO_ROOT && git fetch origin` to get latest refs
2. First time (no existing branch):
- `git worktree add $WORKTREE_BASE/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd $WORKTREE_BASE/fix/<issue-number>-<short-slug> && bun install`
3. If bounced back from reviewer or tester (branch already exists):
- `cd $WORKTREE_BASE/fix/<issue-number>-<short-slug>`
1. cd into the repo path provided in your task prompt
2. `git fetch origin` to get latest refs
3. First time (no existing branch):
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
4. If bounced back from reviewer or tester (branch already exists):
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
4. ALL subsequent work must happen inside the worktree directory.
5. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
5. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
6. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
7. Write tests first based on the spec
8. Implement the code to make tests pass
9. Ensure `bun run build` passes with no errors
10. Run `bun test` to verify all tests pass
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
8. Write tests first based on the spec
9. Implement the code to make tests pass
10. Ensure `bun run build` passes with no errors
11. Run `bun test` to verify all tests pass
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
or repeated attempts fail), set $status=failed with a reason.
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
type: object
properties:
status:
type: string
enum: [done, failed]
required: [status]
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Code standards compliance check"
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
@@ -78,7 +83,7 @@ roles:
- code-review
- static-analysis
procedure: |
Find and cd into the worktree directory for this issue.
The worktree path is provided in your task prompt. cd into it first.
Before reviewing, verify the git branch:
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
@@ -90,135 +95,104 @@ roles:
4. `bunx biome check` — no lint violations
5. TypeScript strict mode — no type errors
Soft checks (review against project conventions file if present):
- Code style and naming conventions
- Module organization
- No debug logging left behind
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
- Naming conventions, module boundaries, code style
- No `console.log` in production code
- No dynamic imports in production code
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
type: object
properties:
approved:
type: boolean
required: [approved]
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
required: [$status, comments, worktree]
tester:
description: "Functional correctness verification"
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
capabilities:
- testing
procedure: |
Find and cd into the worktree directory for this issue.
The worktree path is provided in your task prompt. cd into it first.
1. Run `bun test` for automated test verification
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
- fix_code: tests fail or implementation doesn't match spec → send back to developer
- fix_spec: the spec itself is wrong or incomplete → send back to planner
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
type: object
properties:
status:
type: string
enum: [passed, fix_code, fix_spec]
required: [status]
oneOf:
- properties:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "fix_code" }
report: { type: string }
required: [$status, report]
- properties:
$status: { const: "fix_spec" }
report: { type: string }
required: [$status, report]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
Find and cd into the worktree directory for this issue.
Set up variables:
```
OWNER_REPO=$(git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/')
REPO_ROOT=$(git rev-parse --show-toplevel)
MAIN_REPO=$(cd $REPO_ROOT && git worktree list --porcelain | head -1 | sed 's/worktree //')
```
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --repo $OWNER_REPO --title "..." --description "..."`
- The `--repo` flag is required to work in worktree directories
- PR description must follow: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, log the error clearly, include PR details for manual creation, and mark success=false
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- `cd $MAIN_REPO`
- `git worktree remove $REPO_ROOT`
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
type: object
properties:
success:
type: boolean
required: [success]
conditions:
insufficientInfo:
description: "Planner determined there's not enough info to proceed"
expression: "$last('planner').status = 'insufficient_info'"
devFailed:
description: "Developer failed to implement"
expression: "$last('developer').status = 'failed'"
rejected:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
fixCode:
description: "Tester found code issues"
expression: "$last('tester').status = 'fix_code'"
fixSpec:
description: "Tester found spec issues"
expression: "$last('tester').status = 'fix_spec'"
hookFailed:
description: "Push hook failed"
expression: "$last('committer').success = false"
oneOf:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
- role: "planner"
condition: null
prompt: "Analyze the issue and produce an implementation plan."
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
- role: "$END"
condition: "insufficientInfo"
prompt: "Insufficient information to proceed; end the workflow."
- role: "developer"
condition: null
prompt: "Implement the plan from the planner."
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
developer:
- role: "$END"
condition: "devFailed"
prompt: "Development failed; end the workflow."
- role: "reviewer"
condition: null
prompt: "Send the implementation to the reviewer."
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
- role: "developer"
condition: "rejected"
prompt: "Reviewer rejected the implementation; fix the issues."
- role: "tester"
condition: null
prompt: "Review passed; run tests on the implementation."
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
tester:
- role: "developer"
condition: "fixCode"
prompt: "Tests found code issues; return to developer."
- role: "planner"
condition: "fixSpec"
prompt: "Tests found spec issues; return to planner."
- role: "committer"
condition: null
prompt: "Tests passed; commit and push the changes."
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
- role: "developer"
condition: "hookFailed"
prompt: "Push hook failed; return to developer to fix."
- role: "$END"
condition: null
prompt: "Commit succeeded; complete the workflow."
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
+4 -1
View File
@@ -10,7 +10,6 @@ Monorepo with 4 packages under `packages/`:
|---------|-------------|
| `json-cas` | Core CAS engine — hashing, schema, store, verify, bootstrap |
| `json-cas-fs` | Filesystem-backed CAS store |
| `json-cas-workflow` | Workflow integration layer (schemas + types) |
| `cli-json-cas` | CLI tool |
## Tech Stack
@@ -67,6 +66,10 @@ bun run format # Biome format (auto-fix)
- Reference issues: `Fixes #N` / `Closes #N`
- Author: `小橘 <xiaoju@shazhou.work>`
## Project Rules
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
## Before Submitting
1. `bun test` — all tests pass
+133 -1
View File
@@ -1,3 +1,135 @@
# json-cas
Self-describing content-addressable storage with JSON Schema typed nodes
Self-describing content-addressable storage with JSON Schema typed nodes.
## Overview
json-cas is a monorepo for storing and validating JSON data in a content-addressable store (CAS). Each node has a typed payload: its `type` field is the hash of a JSON Schema node that describes the payload shape. Hashes are 13-character Crockford Base32 strings derived from XXH64 over deterministic CBOR encoding.
A bootstrap meta-schema is stored as a self-referencing seed node (`type === hash`). All other schemas are registered as nodes typed by that meta-schema. Payloads can reference other nodes via `format: "cas_ref"` fields; the library provides traversal, reference extraction, and integrity verification.
Use the in-memory store for tests and embedded apps, the filesystem store for persistence, and the CLI for local store management.
## Architecture
```
┌─────────────────┐
│ cli-json-cas │
└────────┬────────┘
┌─────────────────┐
│ json-cas-fs │
└────────┬────────┘
┌─────────────────┐
│ json-cas │ (core)
└─────────────────┘
```
| Layer | Package | Role |
|-------|---------|------|
| Core | `@uncaged/json-cas` | Hashing, schemas, stores, verify, bootstrap |
| Storage | `@uncaged/json-cas-fs` | Filesystem-backed `Store` |
| CLI | `@uncaged/cli-json-cas` | `json-cas` command-line tool |
## Packages
| Package | Description | Type |
|---------|-------------|------|
| [`@uncaged/json-cas`](packages/json-cas/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
| [`@uncaged/json-cas-fs`](packages/json-cas-fs/README.md) | Filesystem-backed CAS store | lib |
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`json-cas` binary) | cli |
## Quick Start
```bash
git clone <repo-url>
cd json-cas
bun install --no-cache
bun run build
```
```typescript
import {
bootstrap,
createMemoryStore,
putSchema,
validate,
} from "@uncaged/json-cas";
const store = createMemoryStore();
await bootstrap(store);
const typeHash = await putSchema(store, {
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
additionalProperties: false,
});
const hash = await store.put(typeHash, { message: "hello" });
const node = store.get(hash);
console.log(validate(store, node!)); // true
```
For a persistent store:
```typescript
import { createFsStore } from "@uncaged/json-cas-fs";
import { bootstrap } from "@uncaged/json-cas";
const store = createFsStore("/path/to/store");
await bootstrap(store);
```
Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/README.md`](packages/cli-json-cas/README.md)).
## CLI Reference
Binary: `json-cas` (from `@uncaged/cli-json-cas`). Default store: `~/.uncaged/json-cas`.
```
Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
init Create store dir and write bootstrap seed
bootstrap Write meta-schema seed, print hash
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
schema validate <hash> Validate node against its schema
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--json Compact JSON output
```
## Development
```bash
bun install --no-cache # install workspace dependencies
bun run build # tsc --build (libs)
bun run check # biome check
bun run format # biome format --write
bun test # run all package tests
```
## Publishing
Releases use [Changesets](https://github.com/changesets/changesets). From the repo root:
```bash
bun run release # changeset version → build → publish to npm (@uncaged/*)
```
Individual packages block `prepublishOnly` and expect releases via the workspace `release` script.
+13 -12
View File
@@ -10,11 +10,12 @@
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0",
"ulidx": "^2.4.1",
},
},
"packages/cli-json-cas": {
"name": "@uncaged/cli-json-cas",
"version": "0.5.0",
"version": "0.5.3",
"bin": {
"json-cas": "./src/index.ts",
},
@@ -25,28 +26,22 @@
},
"packages/json-cas": {
"name": "@uncaged/json-cas",
"version": "0.5.0",
"version": "0.5.3",
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
"liquidjs": "^10.27.0",
"xxhash-wasm": "^1.1.0",
},
},
"packages/json-cas-fs": {
"name": "@uncaged/json-cas-fs",
"version": "0.5.0",
"version": "0.5.3",
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"cborg": "^4.2.3",
},
},
"packages/json-cas-workflow": {
"name": "@uncaged/json-cas-workflow",
"version": "0.5.0",
"dependencies": {
"@uncaged/json-cas": "workspace:^",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
@@ -127,8 +122,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=="],
@@ -149,6 +142,8 @@
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="],
@@ -209,6 +204,10 @@
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
"liquidjs": ["liquidjs@10.27.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
@@ -291,6 +290,8 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ulidx": ["ulidx@2.4.1", "", { "dependencies": { "layerr": "^3.0.0" } }, "sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+68
View File
@@ -0,0 +1,68 @@
# Sync README
When updating README.md files in this monorepo, follow these conventions.
## Scope
- Root `README.md` — project overview and navigation hub
- Per-package `packages/*/README.md` — each package self-contained
## Root README Structure
The root README should have these sections in order:
1. **Title and one-liner** — content-addressed storage for JSON with schema validation
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
3. **Architecture** — dependency layer diagram (text-based)
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib)
5. **Quick Start** — install, build, basic usage
6. **CLI Reference** — brief command list, detailed usage in cli-json-cas README
7. **Development** — bun install / build / check / test
8. **Publishing** — changeset workflow (bun run release)
## Per-Package README Structure
Each package README should have:
1. **Title** — package name
2. **One-line description** — matching package.json
3. **Overview** — what it does, where it sits in the architecture, dependencies
4. **Installation** — bun add (for libs) or "included as binary" (for cli)
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
6. **CLI Usage** (cli packages) — command reference with examples
7. **Internal Structure** — brief src/ file organization
8. **Configuration** (if applicable)
## Execution Steps
### Step 1: Gather current state
For each package read:
- package.json (name, version, description, dependencies, bin)
- src/index.ts (public API exports)
- Existing README.md (preserve hand-written content worth keeping)
### Step 2: Update root README
- Ensure ALL packages in packages/ directory are listed in the table
- Update CLI command reference from actual --help output
- Keep Quick Start examples valid
### Step 3: Write/update each package README
- Follow the per-package structure
- API section MUST match actual src/index.ts exports — never invent
- For cli packages: document CLI binary name, how it is invoked
- For lib packages: document exported types and functions
- Internal structure: list actual files in src/
### Step 4: Verify
- All relative links work
- Package names match package.json
- No references to removed/renamed packages
- bun run build still passes
## Guidelines
- Only document what src/index.ts actually exports
- Root README summarizes, package READMEs go into detail
- Verify CLI examples against actual commands
- Preserve existing good prose when updating
- English for all README content
+5 -3
View File
@@ -9,12 +9,14 @@
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"ulidx": "^2.4.1"
},
"scripts": {
"build": "tsc --build packages/json-cas packages/json-cas-fs packages/json-cas-workflow",
"build": "tsc --build packages/json-cas packages/json-cas-fs",
"test": "bun test",
"check": "biome check .",
"format": "biome format --write ."
"format": "biome format --write .",
"release": "changeset version && bun run build && changeset publish"
}
}
+8
View File
@@ -1,5 +1,13 @@
# @uncaged/cli-json-cas
## 0.5.3
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.5.3
- @uncaged/json-cas-fs@0.5.3
## 0.3.0
### Patch Changes
+98
View File
@@ -0,0 +1,98 @@
# @uncaged/cli-json-cas
CLI tool for json-cas stores.
## Overview
`@uncaged/cli-json-cas` provides the `json-cas` command for managing a filesystem-backed store: bootstrap, schema registration, node CRUD, integrity checks, reference listing, and graph walks. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
## Installation
Published as an npm package with a binary entry:
```bash
bun add -g @uncaged/cli-json-cas
# or from the monorepo workspace:
bun link
```
**Binary name:** `json-cas` (points to `src/index.ts`, run with Bun).
In development:
```bash
bun packages/cli-json-cas/src/index.ts <command> [args]
```
## CLI Usage
```
Usage: json-cas [--store <path>] [--json] <command> [args]
```
### Global flags
| Flag | Description |
|------|-------------|
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
| `--json` | Compact JSON output for commands that print JSON |
### Commands
| Command | Description |
|---------|-------------|
| `init` | Create store directory and write bootstrap seed; prints meta hash |
| `bootstrap` | Write meta-schema seed into existing store; prints hash |
| `schema put <file.json>` | Register schema from file; prints type hash |
| `schema get <type-hash>` | Print schema JSON |
| `schema list` | List all schemas (`hash name`) |
| `schema validate <hash>` | Validate node against its schema; prints `valid` / `invalid` |
| `put <type-hash> <file.json>` | Store node; prints content hash |
| `get <hash>` | Print full node as JSON |
| `has <hash>` | Print `true` or `false` |
| `verify <hash>` | Verify integrity; prints `ok` or `corrupted` |
| `refs <hash>` | Print direct `cas_ref` targets (one per line) |
| `walk <hash>` | BFS traversal; one hash per line |
| `walk <hash> --format tree` | Tree-formatted traversal |
| `hash <type-hash> <file.json>` | Compute hash without storing |
| `cat <hash>` | Print node JSON |
| `cat <hash> --payload` | Print payload only |
### Examples
```bash
# Initialize default store at ~/.uncaged/json-cas
json-cas init
# Use a custom store path
json-cas --store ./data/cas bootstrap
# Register a schema and store a payload
json-cas schema put ./schemas/item.json
# → prints type hash, e.g. 0123456789ABCD
json-cas put 0123456789ABCD ./payloads/item.json
# → prints content hash
json-cas get <content-hash> --json
json-cas verify <content-hash>
json-cas walk <content-hash> --format tree
```
## Internal Structure
| File | Purpose |
|------|---------|
| `index.ts` | Argument parsing, command dispatch, and all CLI logic |
There is no separate `src/` module tree; the CLI is a single entry file. Tests (if present) are co-located under the package.
## Configuration
| Setting | Default | Override |
|---------|---------|----------|
| Store directory | `~/.uncaged/json-cas` | `--store <path>` |
No config file is read; all behavior is controlled via flags and command arguments.
+7 -5
View File
@@ -1,15 +1,17 @@
{
"name": "@uncaged/cli-json-cas",
"version": "0.5.0",
"version": "0.5.3",
"type": "module",
"bin": {
"json-cas": "./src/index.ts"
"json-cas": "./src/index.ts",
"ucas": "./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"
}
}
+599
View File
@@ -0,0 +1,599 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const pkgPath = resolve(import.meta.dir, "../package.json");
const entrypoint = resolve(import.meta.dir, "index.ts");
async function runCli(
args: string[],
storePath?: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const finalArgs = storePath
? ["bun", entrypoint, "--store", storePath, ...args]
: ["bun", entrypoint, ...args];
const proc = Bun.spawn(finalArgs, {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { stdout, stderr, exitCode };
}
describe("ucas command alias", () => {
test("T1: ucas bin entry exists in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe("./src/index.ts");
});
test("T2: json-cas bin entry is preserved in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin["json-cas"]).toBe("./src/index.ts");
});
test("T3: ucas command is executable and shows help", async () => {
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
test("T4: both commands point to the same entrypoint", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
});
});
// ---- @ Alias Resolution Tests ----
let testDir: string;
let storePath: string;
let cliPath: string;
beforeEach(() => {
// Create unique temp directory for each test
testDir = join(
tmpdir(),
`json-cas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
// Clean up test directory
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
/**
* Run CLI command and return stdout, stderr, and exit code
*/
async function runCliAlias(...args: string[]): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--store", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
}
describe("@ Alias Resolution - schema get", () => {
test("ucas schema get @string should work", async () => {
await runCliAlias("init"); // Initialize store
const { stdout, stderr, exitCode } = await runCliAlias(
"schema",
"get",
"@string",
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const schema = JSON.parse(stdout);
expect(schema).toEqual({ type: "string" });
});
test("ucas schema get @number should work", async () => {
await runCliAlias("init");
const { stdout, exitCode } = await runCliAlias("schema", "get", "@number");
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema).toEqual({ type: "number" });
});
test("ucas schema get @object should work", async () => {
await runCliAlias("init");
const { stdout, exitCode } = await runCliAlias("schema", "get", "@object");
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema).toEqual({ type: "object" });
});
test("ucas schema get @array should work", async () => {
await runCliAlias("init");
const { stdout, exitCode } = await runCliAlias("schema", "get", "@array");
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema).toEqual({ type: "array" });
});
test("ucas schema get @bool should work", async () => {
await runCliAlias("init");
const { stdout, exitCode } = await runCliAlias("schema", "get", "@bool");
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema).toEqual({ type: "boolean" });
});
test("ucas schema get @schema should work", async () => {
await runCliAlias("init");
const { stdout, exitCode } = await runCliAlias("schema", "get", "@schema");
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema).toHaveProperty("type", "object");
expect(schema).toHaveProperty(
"description",
"json-cas JSON Schema meta-schema",
);
});
test("ucas schema get @invalid should fail gracefully", async () => {
await runCliAlias("init");
const { stderr, exitCode } = await runCliAlias("schema", "get", "@invalid");
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Schema not found");
});
});
describe("@ Alias Resolution - put", () => {
test("ucas put @string <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify("hello world"));
const { stdout, stderr, exitCode } = await runCliAlias(
"put",
"@string",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
// Should output a valid hash (13 chars)
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @number <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "42");
const { stdout, exitCode } = await runCliAlias(
"put",
"@number",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @object <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ foo: "bar" }));
const { stdout, exitCode } = await runCliAlias(
"put",
"@object",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @invalid <file> should fail", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "{}");
const { stderr, exitCode } = await runCliAlias(
"put",
"@invalid",
payloadFile,
);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
});
});
describe("@ Alias Resolution - hash", () => {
test("ucas hash @string <file> should compute hash without storing", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify("test"));
const { stdout, stderr, exitCode } = await runCliAlias(
"hash",
"@string",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
describe("ucas render command", () => {
test("R1: render requires hash argument", async () => {
const { exitCode, stderr } = await runCli(["render"]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Usage");
});
test("R2: render with missing hash shows error", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const { exitCode, stdout } = await runCli(
["render", "ZZZZZZZZZZZZZ"],
tmpStore,
);
// Missing hash renders as cas: reference
expect(exitCode).toBe(0);
expect(stdout).toContain("cas:ZZZZZZZZZZZZZ");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R3: render with invalid numeric flag fails", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const { exitCode, stderr } = await runCli(
["render", "AAAAAAAAAAAAA", "--resolution", "invalid"],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("valid number");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
describe("Suite 6: CLI Integration with Templates", () => {
test("6.1 CLI with Template (Default Parameters)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
// Initialize store
await runCli(["init"], tmpStore);
// Create schema
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
// Create node
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice" }));
const { stdout: nodeHash } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
// Create template file (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(templateFile, JSON.stringify("Hello {{ payload.name }}!"));
const { stdout: tmplHash } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
// Register template
await runCli(
[
"var",
"set",
`@ucas/template/text/${schemaHash.trim()}`,
tmplHash.trim(),
],
tmpStore,
);
// Render with template
const { stdout: output, exitCode } = await runCli(
["render", nodeHash.trim()],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toBe("Hello Alice!");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.2 CLI with Template + Custom Decay", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Create schema with child ref
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
value: { type: "string" },
child: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
// Create child node
const childFile = join(tmpStore, "child.json");
writeFileSync(childFile, JSON.stringify({ value: "child", child: null }));
const { stdout: childHash } = await runCli(
["put", schemaHash.trim(), childFile],
tmpStore,
);
// Create parent node
const parentFile = join(tmpStore, "parent.json");
writeFileSync(
parentFile,
JSON.stringify({ value: "parent", child: childHash.trim() }),
);
const { stdout: parentHash } = await runCli(
["put", schemaHash.trim(), parentFile],
tmpStore,
);
// Create template showing resolution (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(
templateFile,
JSON.stringify("{{ payload.value }}(res={{ resolution }})"),
);
const { stdout: tmplHash } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
// Register template
await runCli(
[
"var",
"set",
`@ucas/template/text/${schemaHash.trim()}`,
tmplHash.trim(),
],
tmpStore,
);
// Render with custom decay
const { stdout: output, exitCode } = await runCli(
["render", parentHash.trim(), "--decay", "0.7"],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toContain("parent(res=1)");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.3 CLI with Template + All Parameters", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Bob" }));
const { stdout: nodeHash } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
// Create template (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(
templateFile,
JSON.stringify("Greetings {{ payload.name }}!"),
);
const { stdout: tmplHash } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
await runCli(
[
"var",
"set",
`@ucas/template/text/${schemaHash.trim()}`,
tmplHash.trim(),
],
tmpStore,
);
const { stdout: output, exitCode } = await runCli(
[
"render",
nodeHash.trim(),
"--resolution",
"0.8",
"--decay",
"0.6",
"--epsilon",
"0.005",
],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toBe("Greetings Bob!");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.4 CLI with Non-templated Node (YAML Fallback)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" }));
const { stdout: nodeHash } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
// No template registered - should fall back to YAML
const { stdout: output, exitCode } = await runCli(
["render", nodeHash.trim()],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toContain("name:");
expect(output).toContain("Charlie");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.5 CLI Error: Invalid Decay Value", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Test" }));
const { stdout: nodeHash } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const { exitCode, stderr } = await runCli(
["render", nodeHash.trim(), "--decay", "1.5"],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("decay");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
+656 -19
View File
@@ -1,15 +1,23 @@
#!/usr/bin/env bun
import { mkdirSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
import {
bootstrap,
CasNodeNotFoundError,
computeHash,
createVariableStore,
gc,
getSchema,
InvalidTagFormatError,
InvalidVariableNameError,
putSchema,
refs,
renderAsync,
TagLabelConflictError,
VariableNotFoundError,
validate,
verify,
walk,
@@ -18,10 +26,20 @@ import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Argument parsing ----
type Flags = Record<string, string | boolean>;
type Flags = Record<string, string | boolean | string[]>;
/** 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",
"var-db",
"tag",
"schema",
"resolution",
"decay",
"epsilon",
"inline",
]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
const flags: Flags = {};
@@ -34,7 +52,19 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
if (VALUE_FLAGS.has(key)) {
const next = argv[i + 1];
if (next !== undefined && !next.startsWith("--")) {
flags[key] = next;
// Handle repeatable flags (like --tag)
if (key === "tag") {
const existing = flags[key];
if (Array.isArray(existing)) {
existing.push(next);
} else if (typeof existing === "string") {
flags[key] = [existing, next];
} else {
flags[key] = [next];
}
} else {
flags[key] = next;
}
i++;
} else {
flags[key] = true;
@@ -57,6 +87,10 @@ const storePath =
typeof flags.store === "string" ? flags.store : defaultStorePath;
const compact = flags.json === true;
const defaultVarDbPath = join(storePath, "variables.db");
const varDbPath =
typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath;
// ---- Helpers ----
function out(data: unknown): void {
@@ -80,20 +114,127 @@ function openStore(): Store {
return createFsStore(resolve(storePath));
}
function openVarStore(): VariableStore {
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
return createVariableStore(resolve(varDbPath), store);
}
/**
* Resolve a type-hash, handling @ aliases
* If the input starts with @, resolve it via bootstrap
* Otherwise, return the hash as-is
*/
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
if (typeHashOrAlias.startsWith("@")) {
const store = openStore();
const builtinSchemas = await bootstrap(store);
const resolvedHash = builtinSchemas[typeHashOrAlias];
if (!resolvedHash) {
die(`Schema not found: ${typeHashOrAlias}`);
}
return resolvedHash;
}
return typeHashOrAlias;
}
/**
* Get the Variable schema's CAS hash
* This is the type hash used in JSON envelopes
*/
async function getVariableSchemaHash(): Promise<Hash> {
const store = openStore();
// Define the Variable JSON Schema (updated for new model with composite key)
const variableSchema: JSONSchema = {
title: "Variable",
type: "object",
properties: {
name: { type: "string" },
schema: { type: "string" },
value: { type: "string" },
created: { type: "number" },
updated: { type: "number" },
tags: { type: "object" },
labels: { type: "array", items: { type: "string" } },
},
required: [
"name",
"schema",
"value",
"created",
"updated",
"tags",
"labels",
],
};
// 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,
};
}
/**
* Parse tag/label arguments
* Returns: { tags: Record<string, string>, labels: string[], deleteNames: string[] }
*/
function parseTagsLabels(args: string[]): {
tags: Record<string, string>;
labels: string[];
deleteNames: string[];
} {
const tags: Record<string, string> = {};
const labels: string[] = [];
const deleteNames: string[] = [];
for (const arg of args) {
if (arg.startsWith(":")) {
// Deletion syntax: :name
deleteNames.push(arg.slice(1));
} else if (arg.includes(":")) {
// Tag: key:value (split on first colon)
const colonIdx = arg.indexOf(":");
const key = arg.slice(0, colonIdx);
const value = arg.slice(colonIdx + 1);
tags[key] = value;
} else {
// Label: bare identifier
labels.push(arg);
}
}
return { tags, labels, deleteNames };
}
// ---- Commands ----
async function cmdInit(): Promise<void> {
const dir = resolve(storePath);
mkdirSync(dir, { recursive: true });
const store = createFsStore(dir);
const hash = await bootstrap(store);
console.log(hash);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
console.log(metaHash);
}
async function cmdBootstrap(): Promise<void> {
const store = openStore();
const hash = await bootstrap(store);
console.log(hash);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
console.log(metaHash);
}
async function cmdSchemaPut(args: string[]): Promise<void> {
@@ -106,17 +247,20 @@ async function cmdSchemaPut(args: string[]): Promise<void> {
}
async function cmdSchemaGet(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas schema get <type-hash>");
const hashOrAlias = args[0];
if (!hashOrAlias) die("Usage: json-cas schema get <type-hash>");
const hash = await resolveTypeHash(hashOrAlias);
const store = openStore();
const schema = getSchema(store, hash);
if (schema === null) die(`Schema not found: ${hash}`);
if (schema === null) die(`Schema not found: ${hashOrAlias}`);
out(schema);
}
async function cmdSchemaList(): Promise<void> {
const store = openStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (!metaHash) throw new Error("Meta-schema not found");
for (const hash of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
@@ -142,9 +286,11 @@ async function cmdSchemaValidate(args: string[]): Promise<void> {
}
async function cmdPut(args: string[]): Promise<void> {
const typeHash = args[0];
const typeHashOrAlias = args[0];
const file = args[1];
if (!typeHash || !file) die("Usage: json-cas put <type-hash> <file.json>");
if (!typeHashOrAlias || !file)
die("Usage: json-cas put <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = readJsonFile(file);
const store = openStore();
const hash = await store.put(typeHash, payload);
@@ -229,14 +375,69 @@ async function cmdWalk(args: string[]): Promise<void> {
}
async function cmdHash(args: string[]): Promise<void> {
const typeHash = args[0];
const typeHashOrAlias = args[0];
const file = args[1];
if (!typeHash || !file) die("Usage: json-cas hash <type-hash> <file.json>");
if (!typeHashOrAlias || !file)
die("Usage: json-cas hash <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = readJsonFile(file);
const hash = await computeHash(typeHash, payload);
console.log(hash);
}
async function cmdRender(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) {
die(
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]",
);
}
const store = openStore();
// Parse numeric options
const resolution =
typeof flags.resolution === "string"
? Number.parseFloat(flags.resolution)
: undefined;
const decay =
typeof flags.decay === "string"
? Number.parseFloat(flags.decay)
: undefined;
const epsilon =
typeof flags.epsilon === "string"
? Number.parseFloat(flags.epsilon)
: undefined;
// Validate numeric values
if (resolution !== undefined && Number.isNaN(resolution)) {
die("--resolution must be a valid number");
}
if (decay !== undefined && Number.isNaN(decay)) {
die("--decay must be a valid number");
}
if (epsilon !== undefined && Number.isNaN(epsilon)) {
die("--epsilon must be a valid number");
}
try {
const varStore = openVarStore();
const output = await renderAsync(store, hash, {
resolution,
decay,
epsilon,
varStore,
});
// Output to stdout without JSON wrapping (raw output)
process.stdout.write(output);
} catch (error) {
if (error instanceof Error) {
die(error.message);
}
die(String(error));
}
}
async function cmdCat(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas cat <hash>");
@@ -250,6 +451,371 @@ async function cmdCat(args: string[]): Promise<void> {
}
}
async function cmdVarSet(args: string[]): Promise<void> {
const name = args[0];
const value = args[1];
const tagFlags = flags.tag;
if (!name || !value) {
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
}
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
const tagArgs = Array.isArray(tagFlags)
? tagFlags
: typeof tagFlags === "string"
? [tagFlags]
: [];
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
// Check for conflicts in initial tags/labels
if (deleteNames.length > 0) {
die("Error: Cannot use deletion syntax (:name) in var set");
}
// If --tag flags are provided at all, always pass options to replace tags/labels
// If no --tag flags, pass undefined to preserve existing tags/labels
const options =
tagArgs.length > 0
? {
tags: Object.keys(tags).length > 0 ? tags : {},
labels: labels.length > 0 ? labels : [],
}
: undefined;
const variable = varStore.set(name, value, options);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof InvalidVariableNameError ||
e instanceof CasNodeNotFoundError ||
e instanceof TagLabelConflictError
) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarGet(args: string[]): Promise<void> {
const name = args[0];
const schema = flags.schema as string | undefined;
if (!name || !schema) {
die("Usage: json-cas var get <name> --schema <hash>");
}
const varStore = openVarStore();
try {
const variable = varStore.get(name, schema);
if (variable === null) {
die(`Error: Variable not found: name=${name}, schema=${schema}`);
}
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} finally {
varStore.close();
}
}
async function cmdVarDelete(args: string[]): Promise<void> {
const name = args[0];
const schema = flags.schema as string | undefined;
if (!name) {
die("Usage: json-cas var delete <name> [--schema <hash>]");
}
const varStore = openVarStore();
try {
if (schema !== undefined) {
// Precise deletion: remove specific (name, schema) variant
const variable = varStore.remove(name, schema);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} else {
// Batch deletion: remove all variants for this name
const variables = varStore.remove(name);
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
}
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarTag(args: string[]): Promise<void> {
const name = args[0];
const schema = flags.schema as string | undefined;
if (!name || !schema) {
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const tagArgs = args.slice(1);
if (tagArgs.length === 0) {
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const varStore = openVarStore();
try {
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
const variable = varStore.tag(name, schema, {
add: Object.keys(tags).length > 0 ? tags : undefined,
addLabels: labels.length > 0 ? labels : undefined,
delete: deleteNames.length > 0 ? deleteNames : undefined,
});
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
e instanceof TagLabelConflictError ||
e instanceof InvalidTagFormatError
) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarList(args: string[]): Promise<void> {
const namePrefix = args[0] ?? "";
const schema = flags.schema as string | undefined;
const tagFlags = flags.tag;
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
const tagArgs = Array.isArray(tagFlags)
? tagFlags
: typeof tagFlags === "string"
? [tagFlags]
: [];
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
// Check for invalid deletion syntax in filters
if (deleteNames.length > 0) {
die("Error: Cannot use deletion syntax (:name) in var list filters");
}
const variables = varStore.list({
namePrefix,
schema,
tags: Object.keys(tags).length > 0 ? tags : undefined,
labels: labels.length > 0 ? labels : undefined,
});
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
} catch (e) {
if (e instanceof InvalidVariableNameError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdTemplateSet(args: string[]): Promise<void> {
const schemaHash = args[0];
const inlineFlag = flags.inline;
if (!schemaHash) {
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
}
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
// Validate schema hash exists in CAS
if (!store.has(schemaHash)) {
die(`Error: Schema hash not found in CAS: ${schemaHash}`);
}
// Determine content source
let content: string;
if (typeof inlineFlag === "string") {
// --inline mode
const fileArg = args[1];
if (fileArg !== undefined && !fileArg.startsWith("--")) {
die("Error: Cannot specify both file and --inline");
}
content = inlineFlag;
} else if (inlineFlag === true) {
// --inline flag present but no value
const contentArg = args[1];
if (!contentArg) {
die(
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
);
}
content = contentArg;
} else {
// File mode
const file = args[1];
if (!file) {
die(
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
);
}
if (!existsSync(file)) {
die(`Error: File not found: ${file}`);
}
content = readFileSync(file, "utf-8");
}
// Store content in CAS under @string schema
const stringHash = await resolveTypeHash("@string");
const contentHash = await store.put(stringHash, content);
// Create variable binding: @ucas/template/text/<schema-hash>
const varName = `@ucas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
out({
schemaHash,
contentHash,
});
} catch (e) {
if (e instanceof CasNodeNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdTemplateGet(args: string[]): Promise<void> {
const schemaHash = args[0];
if (!schemaHash) {
die("Usage: json-cas template get <schema-hash>");
}
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
const varName = `@ucas/template/text/${schemaHash}`;
const stringHash = await resolveTypeHash("@string");
const variable = varStore.get(varName, stringHash);
if (variable === null) {
die(`Error: Template not found for schema: ${schemaHash}`);
}
// Get the content from CAS
const node = store.get(variable.value);
if (node === null) {
die(`Error: Content not found in CAS: ${variable.value}`);
}
// Output raw text (not JSON)
process.stdout.write(node.payload as string);
} finally {
varStore.close();
}
}
async function cmdTemplateList(_args: string[]): Promise<void> {
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
const stringHash = await resolveTypeHash("@string");
const variables = varStore.list({
namePrefix: "@ucas/template/text/",
schema: stringHash,
});
const templates = variables.map((v) => {
const schemaHash = v.name.replace("@ucas/template/text/", "");
// Get content for preview
const node = store.get(v.value);
const content = (node?.payload as string | undefined) ?? "";
// Truncate preview to 80 chars
const preview =
content.length > 80 ? `${content.slice(0, 77)}...` : content;
return {
schemaHash,
preview,
};
});
out(templates);
} finally {
varStore.close();
}
}
async function cmdTemplateDelete(args: string[]): Promise<void> {
const schemaHash = args[0];
if (!schemaHash) {
die("Usage: json-cas template delete <schema-hash>");
}
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
const varName = `@ucas/template/text/${schemaHash}`;
const stringHash = await resolveTypeHash("@string");
varStore.remove(varName, stringHash);
out({ deleted: true });
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: Template not found for schema: ${schemaHash}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdGc(_args: string[]): Promise<void> {
const store = createFsStore(storePath);
const varStore = createVariableStore(varDbPath, store);
try {
const stats = gc(store, varStore);
out(stats);
} finally {
varStore.close();
}
}
function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
@@ -268,11 +834,29 @@ Commands:
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
render <hash> [options] Render node as YAML with resolution decay
cat <hash> [--payload] Output node (--payload for payload only)
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
var delete <name> [--schema <hash>] Delete variable(s)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables
var tag <name> --schema <hash> <operations...> Modify tags/labels
template set <schema-hash> <file> | --inline <text> Set template for schema
template get <schema-hash> Get template content as raw text
template list List all templates
template delete <schema-hash> Delete template for schema
gc Run garbage collection
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--json Compact JSON output`);
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)`);
}
// ---- Dispatch ----
@@ -342,10 +926,63 @@ switch (cmd) {
await cmdHash(rest);
break;
case "render":
await cmdRender(rest);
break;
case "cat":
await cmdCat(rest);
break;
case "var": {
const [sub, ...subRest] = rest;
switch (sub) {
case "set":
await cmdVarSet(subRest);
break;
case "get":
await cmdVarGet(subRest);
break;
case "delete":
await cmdVarDelete(subRest);
break;
case "tag":
await cmdVarTag(subRest);
break;
case "list":
await cmdVarList(subRest);
break;
default:
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
}
break;
}
case "template": {
const [sub, ...subRest] = rest;
switch (sub) {
case "set":
await cmdTemplateSet(subRest);
break;
case "get":
await cmdTemplateGet(subRest);
break;
case "list":
await cmdTemplateList(subRest);
break;
case "delete":
await cmdTemplateDelete(subRest);
break;
default:
die(`Unknown template subcommand: ${sub ?? "(none)"}`);
}
break;
}
case "gc":
await cmdGc(rest);
break;
default:
die(`Unknown command: ${cmd}`);
}
+648
View File
@@ -0,0 +1,648 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@uncaged/json-cas";
import { bootstrap } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Test helpers ----
let testDir: string;
let storePath: string;
let varDbPath: string;
let cliPath: string;
beforeEach(() => {
// Create unique temp directory for each test
testDir = join(
tmpdir(),
`json-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
varDbPath = join(testDir, "variables.db");
cliPath = join(import.meta.dir, "index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
// Clean up test directory
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
/**
* Run CLI command and return stdout, stderr, and exit code
*/
async function runCli(...args: string[]): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
[
"bun",
"run",
cliPath,
"--store",
storePath,
"--var-db",
varDbPath,
...args,
],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
}
/**
* Get bootstrap @string type hash
*/
async function getStringHash(store: Store): Promise<Hash> {
const builtinSchemas = await bootstrap(store);
return builtinSchemas["@string"] ?? "";
}
// ---- Tests ----
describe("template set", () => {
test("set template from file", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const templateFile = join(testDir, "template.txt");
writeFileSync(templateFile, "Hello {{name}}!");
const { stdout, stderr, exitCode } = await runCli(
"template",
"set",
stringHash,
templateFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
});
test("set template with --inline flag", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stdout, exitCode } = await runCli(
"template",
"set",
stringHash,
"--inline",
"Inline template content",
);
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
});
test("update existing template (idempotent)", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const templateFile = join(testDir, "template.txt");
writeFileSync(templateFile, "Version 1");
// Set first time
await runCli("template", "set", stringHash, templateFile);
// Update with new content
writeFileSync(templateFile, "Version 2");
const { stdout, exitCode } = await runCli(
"template",
"set",
stringHash,
templateFile,
);
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
// Verify we can get the new version
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe("Version 2");
});
test("error when file not found", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli(
"template",
"set",
stringHash,
"/nonexistent/file.txt",
);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error:");
});
test("error when schema hash invalid", async () => {
const templateFile = join(testDir, "template.txt");
writeFileSync(templateFile, "content");
const { stderr, exitCode } = await runCli(
"template",
"set",
"INVALID_HASH",
templateFile,
);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error:");
});
test("error when both file and --inline provided", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const templateFile = join(testDir, "template.txt");
writeFileSync(templateFile, "content");
const { stderr, exitCode } = await runCli(
"template",
"set",
stringHash,
templateFile,
"--inline",
"inline content",
);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error:");
});
test("support multi-line templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const multilineContent = "Line 1\nLine 2\nLine 3";
const { exitCode } = await runCli(
"template",
"set",
stringHash,
"--inline",
multilineContent,
);
expect(exitCode).toBe(0);
// Verify content
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe(multilineContent);
});
test("support empty templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stdout, exitCode } = await runCli(
"template",
"set",
stringHash,
"--inline",
"",
);
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
});
test("error when neither file nor --inline provided", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli("template", "set", stringHash);
expect(exitCode).toBe(1);
expect(stderr).toContain("Usage:");
});
test("support templates with special characters", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const specialContent = "Template with {{var}} and $env and @ref";
const { exitCode } = await runCli(
"template",
"set",
stringHash,
"--inline",
specialContent,
);
expect(exitCode).toBe(0);
// Verify content preserved
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe(specialContent);
});
});
describe("template get", () => {
test("retrieve template as raw text", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const content = "Hello {{name}}!";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout, stderr, exitCode } = await runCli(
"template",
"get",
stringHash,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toBe(content);
});
test("error when template not found", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli("template", "get", stringHash);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error:");
expect(stderr).toContain("not found");
});
test("preserve exact whitespace", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Note: runCli helper trims stdout, so we test with content that doesn't have leading/trailing whitespace
// The actual CLI preserves whitespace correctly
const content = "spaces\n\ttabs\t\nmixed";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout } = await runCli("template", "get", stringHash);
expect(stdout).toBe(content);
});
test("support multi-line templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Note: runCli helper trims stdout, so trailing newline will be removed
const multiline = "Line 1\nLine 2\nLine 3";
await runCli("template", "set", stringHash, "--inline", multiline);
const { stdout } = await runCli("template", "get", stringHash);
expect(stdout).toBe(multiline);
});
});
describe("template list", () => {
test("list all templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Create multiple templates
await runCli("template", "set", stringHash, "--inline", "Template 1");
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
const { stdout, exitCode } = await runCli("template", "list");
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBeGreaterThanOrEqual(1);
// Check structure
const item = output[0];
expect(item).toHaveProperty("schemaHash");
expect(item).toHaveProperty("preview");
});
test("preview truncation for long content", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const longContent = "a".repeat(200);
await runCli("template", "set", stringHash, "--inline", longContent);
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
preview: string;
}>;
const item = output.find((i) => i.schemaHash === stringHash);
expect(item).toBeDefined();
if (item) {
expect(item.preview.length).toBeLessThan(longContent.length);
expect(item.preview).toContain("...");
}
});
test("empty list when no templates", async () => {
const { stdout, exitCode } = await runCli("template", "list");
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBe(0);
});
test("exclude non-template variables", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Create a template
await runCli("template", "set", stringHash, "--inline", "Template");
// Create a regular variable (not under @ucas/template/text/)
const hash = await store.put(stringHash, "regular var content");
await runCli("var", "set", "regular/var", hash);
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout);
// Should only contain template variables
for (const item of output) {
expect(item.schemaHash).toBeDefined();
}
});
test("output JSON array format", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Test");
const { stdout } = await runCli("template", "list");
// Should be valid JSON
expect(() => JSON.parse(stdout)).not.toThrow();
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
});
test("preview shows beginning of content", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const content = "Start of template...";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
preview: string;
}>;
const item = output.find((i) => i.schemaHash === stringHash);
if (item) {
expect(item.preview).toContain("Start");
}
});
});
describe("template delete", () => {
test("delete template variable binding", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Template");
const { stdout, stderr, exitCode } = await runCli(
"template",
"delete",
stringHash,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("deleted");
expect(output.deleted).toBe(true);
// Verify template is gone
const { exitCode: getExitCode } = await runCli(
"template",
"get",
stringHash,
);
expect(getExitCode).toBe(1);
});
test("error when template not found", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli("template", "delete", stringHash);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error:");
expect(stderr).toContain("not found");
});
test("deletion does not affect other templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Create two templates
await runCli("template", "set", stringHash, "--inline", "Template 1");
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
// Delete first template
await runCli("template", "delete", stringHash);
// Verify second still exists
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
preview: string;
}>;
// Should not find deleted template
const deleted = output.find((i) => i.schemaHash === stringHash);
expect(deleted).toBeUndefined();
});
test("CAS content remains after variable deletion", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Content");
// Get the content hash before deletion
const { stdout: setOut } = await runCli(
"template",
"set",
stringHash,
"--inline",
"Content",
);
const { contentHash } = JSON.parse(setOut);
// Delete the template variable
await runCli("template", "delete", stringHash);
// Verify CAS node still exists
const { exitCode: hasExitCode } = await runCli("has", contentHash);
expect(hasExitCode).toBe(0);
});
test("deletion is non-idempotent (second delete fails)", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Template");
// First deletion succeeds
const { exitCode: firstExit } = await runCli(
"template",
"delete",
stringHash,
);
expect(firstExit).toBe(0);
// Second deletion fails
const { exitCode: secondExit } = await runCli(
"template",
"delete",
stringHash,
);
expect(secondExit).toBe(1);
});
});
describe("template integration", () => {
test("end-to-end workflow: set→get→list→delete", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const content = "Integration test template";
// Set
const { exitCode: setExit } = await runCli(
"template",
"set",
stringHash,
"--inline",
content,
);
expect(setExit).toBe(0);
// Get
const { stdout: getOut, exitCode: getExit } = await runCli(
"template",
"get",
stringHash,
);
expect(getExit).toBe(0);
expect(getOut).toBe(content);
// List
const { stdout: listOut, exitCode: listExit } = await runCli(
"template",
"list",
);
expect(listExit).toBe(0);
const listData = JSON.parse(listOut);
expect(listData.length).toBeGreaterThan(0);
// Delete
const { exitCode: delExit } = await runCli(
"template",
"delete",
stringHash,
);
expect(delExit).toBe(0);
// Verify deleted
const { exitCode: finalGet } = await runCli("template", "get", stringHash);
expect(finalGet).toBe(1);
});
test("templates compatible with generic var commands", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Set via template command
await runCli("template", "set", stringHash, "--inline", "Content");
// List via var command - should see template variable
const { stdout } = await runCli("var", "list", "@ucas/template/text/");
const output = JSON.parse(stdout);
expect(output.value.length).toBeGreaterThan(0);
});
test("multiple templates for different schemas", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Create templates for different schemas
await runCli("template", "set", stringHash, "--inline", "Template 1");
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
await runCli("template", "set", "SCHEMA_HASH_3", "--inline", "Template 3");
// List should show all
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout);
expect(output.length).toBeGreaterThanOrEqual(1);
});
});
describe("template error handling", () => {
test("unknown template subcommand", async () => {
const { stderr, exitCode } = await runCli("template", "unknown");
expect(exitCode).toBe(1);
expect(stderr).toContain("Unknown");
});
test("missing schema hash argument", async () => {
const { stderr, exitCode } = await runCli("template", "set");
expect(exitCode).toBe(1);
expect(stderr).toContain("Usage:");
});
});
File diff suppressed because it is too large Load Diff
+13
View File
@@ -1,5 +1,18 @@
# @uncaged/json-cas-fs
## 0.5.3
### Patch Changes
- feat: add oneOf support to meta-schema validation
Added `oneOf` to `ALLOWED_SCHEMA_KEYS` and corresponding validation logic
in `isValidSchema`. This enables workflow frontmatter schemas that use
`oneOf` discriminated unions for multi-exit role definitions.
- Updated dependencies []:
- @uncaged/json-cas@0.5.3
## 0.3.0
### Minor Changes
+67
View File
@@ -0,0 +1,67 @@
# @uncaged/json-cas-fs
Filesystem-backed CAS store.
## Overview
`@uncaged/json-cas-fs` implements a persistent `Store` on disk. Each node is stored as `<hash>.bin` (CBOR-encoded `CasNode`). A `_index/` directory maps type hashes to content hashes for `listByType`. Stores support bootstrap via the same `BOOTSTRAP_STORE` symbol as the in-memory implementation.
Depends on `@uncaged/json-cas` for hashing, CBOR encoding, and types.
**Dependencies:** `@uncaged/json-cas`, `cborg`
## Installation
```bash
bun add @uncaged/json-cas-fs
```
## API
Exported from `src/index.ts`:
```typescript
function createFsStore(dir: string): BootstrapCapableStore;
```
Returns a `BootstrapCapableStore` from `@uncaged/json-cas`. The store loads existing `.bin` files on open and migrates or builds the type index on first use.
### Example
```typescript
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
const store = createFsStore("./my-cas-store");
await bootstrap(store);
const typeHash = await putSchema(store, {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
});
const hash = await store.put(typeHash, { id: "item-1" });
console.log(store.has(hash)); // true after restart if same dir
```
### On-disk layout
```
my-cas-store/
├── <hash>.bin # CBOR CasNode
├── _index/
│ └── <typeHash> # newline-separated content hashes
└── ...
```
Writes use atomic rename (`<hash>.tmp``<hash>.bin`).
## Internal Structure
| File | Purpose |
|------|---------|
| `store.ts` | `createFsStore`, load/save nodes and type index |
| `index.ts` | Public export |
| `store.test.ts` | Filesystem store tests |
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas-fs",
"version": "0.5.0",
"version": "0.5.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -15,10 +15,11 @@
"src"
],
"scripts": {
"test": "bun test"
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"@uncaged/json-cas": "^0.5.3",
"cborg": "^4.2.3"
}
}
+11 -7
View File
@@ -43,7 +43,8 @@ describe("createFsStore – init and bootstrap", () => {
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
const store = createFsStore(dir);
const hash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const hash = builtinSchemas["@schema"] ?? "";
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
@@ -57,8 +58,8 @@ describe("createFsStore – init and bootstrap", () => {
const h1 = await bootstrap(store);
const h2 = await bootstrap(store);
expect(h1).toBe(h2);
expect(store.listByType(h1)).toHaveLength(1);
expect(h1).toEqual(h2);
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
});
});
@@ -104,7 +105,8 @@ describe("createFsStore – persistence round-trip", () => {
test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => {
const store1 = createFsStore(dir);
const hash = await bootstrap(store1);
const builtinSchemas = await bootstrap(store1);
const hash = builtinSchemas["@schema"] ?? "";
const store2 = createFsStore(dir);
const node = store2.get(hash) as CasNode;
@@ -251,10 +253,11 @@ describe("createFsStore – listByType", () => {
test("bootstrap node is listed under its self type after reload", async () => {
const store1 = createFsStore(dir);
const hash = await bootstrap(store1);
const builtinSchemas = await bootstrap(store1);
const hash = builtinSchemas["@schema"] ?? "";
const store2 = createFsStore(dir);
expect(store2.listByType(hash)).toEqual([hash]);
expect(store2.listByType(hash)).toContain(hash);
});
});
@@ -284,7 +287,8 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
test("verify passes on a disk-loaded bootstrap node", async () => {
const store1 = createFsStore(dir);
const hash = await bootstrap(store1);
const builtinSchemas = await bootstrap(store1);
const hash = builtinSchemas["@schema"] ?? "";
const store2 = createFsStore(dir);
const node = store2.get(hash) as CasNode;
+39
View File
@@ -5,6 +5,7 @@ import {
readdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
@@ -175,6 +176,44 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return typeIndex.get(typeHash) ?? [];
},
listAll(): Hash[] {
return Array.from(data.keys());
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
data.delete(hash);
// Delete file
try {
unlinkSync(join(dir, `${hash}.bin`));
} catch {
// ignore if file doesn't exist
}
// Remove from type index
const list = typeIndex.get(node.type);
if (list) {
const idx = list.indexOf(hash);
if (idx !== -1) {
list.splice(idx, 1);
}
if (list.length === 0) {
typeIndex.delete(node.type);
// Delete empty index file
try {
unlinkSync(join(indexDir, node.type));
} catch {
// ignore
}
} else {
// Rewrite index file
const body = `${list.join("\n")}\n`;
writeFileSync(join(indexDir, node.type), body, "utf8");
}
}
}
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
-24
View File
@@ -1,24 +0,0 @@
# @uncaged/json-cas-workflow
## 0.3.0
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.3.0
## 0.2.0
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.2.0
## 0.1.3
### Patch Changes
- fix: replace workspace:^ with actual version numbers in published dependencies
- Updated dependencies []:
- @uncaged/json-cas@0.1.3
-23
View File
@@ -1,23 +0,0 @@
{
"name": "@uncaged/json-cas-workflow",
"version": "0.5.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "workspace:^"
}
}
@@ -1,646 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { CasNode } from "@uncaged/json-cas";
import {
createMemoryStore,
getSchema,
refs,
validate,
walk,
} from "@uncaged/json-cas";
import type { WorkflowSchemaHashes } from "./schemas.js";
import { registerWorkflowSchemas } from "./schemas.js";
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: registerWorkflowSchemas() — registers all 11 schemas
// ─────────────────────────────────────────────────────────────────────────────
describe("registerWorkflowSchemas", () => {
test("returns an object with all 11 schema hashes", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
const keys: (keyof WorkflowSchemaHashes)[] = [
"agent",
"roleSchema",
"role",
"workflow",
"threadStart",
"threadStep",
"threadEnd",
"content",
"reactSession",
"reactTurn",
"reactToolCall",
];
expect(Object.keys(hashes)).toHaveLength(11);
for (const key of keys) {
expect(hashes[key]).toBeDefined();
}
});
test("all hashes are valid 13-char Crockford Base32 strings", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
for (const hash of Object.values(hashes)) {
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("all 11 hashes are distinct", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
const values = Object.values(hashes);
const unique = new Set(values);
expect(unique.size).toBe(11);
});
test("is idempotent: repeated calls return the same hashes", async () => {
const store = createMemoryStore();
const first = await registerWorkflowSchemas(store);
const second = await registerWorkflowSchemas(store);
for (const key of Object.keys(first) as (keyof WorkflowSchemaHashes)[]) {
expect(first[key]).toBe(second[key]);
}
});
test("schemas are stored in the store (getSchema returns non-null)", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
for (const hash of Object.values(hashes)) {
expect(getSchema(store, hash)).not.toBeNull();
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: getSchema() — schema round-trip for each of the 11 types
// ─────────────────────────────────────────────────────────────────────────────
describe("getSchema round-trip", () => {
test("agent schema has the expected properties", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const schema = getSchema(store, agent);
expect(schema).not.toBeNull();
expect(schema?.type).toBe("object");
const props = schema?.properties as Record<string, unknown>;
expect(props).toHaveProperty("package");
expect(props).toHaveProperty("version");
expect(props).toHaveProperty("config");
});
test("role schema references cas_ref for the schema field", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const schema = getSchema(store, role);
expect(schema).not.toBeNull();
const props = schema?.properties as Record<string, { format?: string }>;
expect(props.schema?.format).toBe("cas_ref");
});
test("thread-step schema has six required fields", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const schema = getSchema(store, threadStep);
expect(schema?.required).toHaveLength(6);
});
test("react-turn schema has nested tokens object", async () => {
const store = createMemoryStore();
const { reactTurn } = await registerWorkflowSchemas(store);
const schema = getSchema(store, reactTurn);
const props = schema?.properties as Record<
string,
{ type: string; properties?: unknown }
>;
expect(props.tokens?.type).toBe("object");
expect(props.tokens?.properties).toBeDefined();
});
test("workflow schema has roles with additionalProperties cas_ref", async () => {
const store = createMemoryStore();
const { workflow } = await registerWorkflowSchemas(store);
const schema = getSchema(store, workflow);
const props = schema?.properties as Record<
string,
{ additionalProperties?: { format?: string } }
>;
expect(props.roles?.additionalProperties?.format).toBe("cas_ref");
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 3: validate() — correct payloads pass for all 11 schema types
// ─────────────────────────────────────────────────────────────────────────────
describe("validate – valid payloads", () => {
const HASH = "AAAAAAAAAAAAA";
test("agent payload is valid", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const h = await store.put(agent, {
package: "gpt-4o",
version: "2024-11",
config: { temperature: 0.7 },
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("role-schema payload is valid (any object)", async () => {
const store = createMemoryStore();
const { roleSchema } = await registerWorkflowSchemas(store);
const h = await store.put(roleSchema, {
type: "object",
properties: { answer: { type: "string" } },
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("role payload is valid", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const h = await store.put(role, {
name: "analyst",
description: "Analyses data",
systemPrompt: "You are an analyst.",
extractPrompt: "Extract the findings.",
schema: HASH,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("workflow payload is valid", async () => {
const store = createMemoryStore();
const { workflow } = await registerWorkflowSchemas(store);
const h = await store.put(workflow, {
name: "research",
description: "Research workflow",
roles: { analyst: HASH },
moderator: [{ from: "analyst", to: "analyst", when: null }],
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-start payload is valid (null parentThread)", async () => {
const store = createMemoryStore();
const { threadStart } = await registerWorkflowSchemas(store);
const h = await store.put(threadStart, {
workflow: HASH,
input: "hello",
depth: 0,
parentThread: null,
agents: { main: HASH },
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-start payload is valid (non-null parentThread)", async () => {
const store = createMemoryStore();
const { threadStart } = await registerWorkflowSchemas(store);
const h = await store.put(threadStart, {
workflow: HASH,
input: "nested",
depth: 1,
parentThread: HASH,
agents: {},
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-step payload is valid (null previous)", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const h = await store.put(threadStep, {
role: "analyst",
meta: { attempt: 1 },
content: HASH,
react: HASH,
start: HASH,
previous: null,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-step payload is valid (non-null previous)", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const h = await store.put(threadStep, {
role: "analyst",
meta: {},
content: HASH,
react: HASH,
start: HASH,
previous: HASH,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("thread-end payload is valid", async () => {
const store = createMemoryStore();
const { threadEnd } = await registerWorkflowSchemas(store);
const h = await store.put(threadEnd, {
returnCode: 0,
summary: "Done",
start: HASH,
lastStep: HASH,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("content payload is valid", async () => {
const store = createMemoryStore();
const { content } = await registerWorkflowSchemas(store);
const h = await store.put(content, { text: "Hello, world!" });
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-session payload is valid (empty turns)", async () => {
const store = createMemoryStore();
const { reactSession } = await registerWorkflowSchemas(store);
const h = await store.put(reactSession, {
agent: HASH,
role: "analyst",
turns: [],
totalTokens: 0,
durationMs: 42,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-session payload is valid (multiple turns)", async () => {
const store = createMemoryStore();
const { reactSession } = await registerWorkflowSchemas(store);
const h = await store.put(reactSession, {
agent: HASH,
role: "analyst",
turns: [HASH, HASH],
totalTokens: 300,
durationMs: 1500,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-turn payload is valid", async () => {
const store = createMemoryStore();
const { reactTurn } = await registerWorkflowSchemas(store);
const h = await store.put(reactTurn, {
input: HASH,
output: HASH,
toolCalls: [HASH],
tokens: { input: 100, output: 50 },
latencyMs: 800,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
test("react-tool-call payload is valid", async () => {
const store = createMemoryStore();
const { reactToolCall } = await registerWorkflowSchemas(store);
const h = await store.put(reactToolCall, {
name: "search",
arguments: HASH,
result: HASH,
durationMs: 200,
});
expect(validate(store, store.get(h) as CasNode)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 4: validate() — invalid payloads fail for representative types
// ─────────────────────────────────────────────────────────────────────────────
describe("validate – invalid payloads", () => {
test("agent: missing required field fails", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const h = await store.put(agent, { package: "gpt-4o", version: "1" });
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("agent: wrong type for config fails", async () => {
const store = createMemoryStore();
const { agent } = await registerWorkflowSchemas(store);
const h = await store.put(agent, {
package: "gpt-4o",
version: "1",
config: "not-an-object",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("role: missing systemPrompt fails", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const h = await store.put(role, {
name: "analyst",
description: "d",
extractPrompt: "e",
schema: "AAAAAAAAAAAAA",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("thread-start: missing depth fails", async () => {
const store = createMemoryStore();
const { threadStart } = await registerWorkflowSchemas(store);
const h = await store.put(threadStart, {
workflow: "AAAAAAAAAAAAA",
input: "hi",
parentThread: null,
agents: {},
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("thread-end: returnCode as string fails", async () => {
const store = createMemoryStore();
const { threadEnd } = await registerWorkflowSchemas(store);
const h = await store.put(threadEnd, {
returnCode: "ok",
summary: "Done",
start: "AAAAAAAAAAAAA",
lastStep: "AAAAAAAAAAAAA",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("content: missing text fails", async () => {
const store = createMemoryStore();
const { content } = await registerWorkflowSchemas(store);
const h = await store.put(content, {});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("react-turn: tokens.input as string fails", async () => {
const store = createMemoryStore();
const { reactTurn } = await registerWorkflowSchemas(store);
const h = await store.put(reactTurn, {
input: "AAAAAAAAAAAAA",
output: "AAAAAAAAAAAAA",
toolCalls: [],
tokens: { input: "many", output: 50 },
latencyMs: 100,
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
test("react-tool-call: missing durationMs fails", async () => {
const store = createMemoryStore();
const { reactToolCall } = await registerWorkflowSchemas(store);
const h = await store.put(reactToolCall, {
name: "tool",
arguments: "AAAAAAAAAAAAA",
result: "AAAAAAAAAAAAA",
});
expect(validate(store, store.get(h) as CasNode)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 5: refs() — extracts direct cas_ref fields from node payloads
// ─────────────────────────────────────────────────────────────────────────────
describe("refs – cas_ref extraction", () => {
const HASH_A = "AAAAAAAAAAAAA";
const HASH_B = "BBBBBBBBBBBBB";
test("content node has no cas_ref fields → empty array", async () => {
const store = createMemoryStore();
const { content } = await registerWorkflowSchemas(store);
const h = await store.put(content, { text: "hello" });
const node = store.get(h) as CasNode;
expect(refs(store, node)).toEqual([]);
});
test("role node: refs() returns the schema cas_ref", async () => {
const store = createMemoryStore();
const { role } = await registerWorkflowSchemas(store);
const h = await store.put(role, {
name: "r",
description: "d",
systemPrompt: "s",
extractPrompt: "e",
schema: HASH_A,
});
const node = store.get(h) as CasNode;
expect(refs(store, node)).toContain(HASH_A);
});
test("thread-end: refs() returns start and lastStep", async () => {
const store = createMemoryStore();
const { threadEnd } = await registerWorkflowSchemas(store);
const h = await store.put(threadEnd, {
returnCode: 0,
summary: "done",
start: HASH_A,
lastStep: HASH_B,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_A);
expect(result).toContain(HASH_B);
expect(result).toHaveLength(2);
});
test("react-tool-call: refs() returns arguments and result", async () => {
const store = createMemoryStore();
const { reactToolCall } = await registerWorkflowSchemas(store);
const h = await store.put(reactToolCall, {
name: "search",
arguments: HASH_A,
result: HASH_B,
durationMs: 100,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_A);
expect(result).toContain(HASH_B);
expect(result).toHaveLength(2);
});
test("thread-step: refs() returns content, react, and start (previous null is skipped)", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const h = await store.put(threadStep, {
role: "r",
meta: {},
content: HASH_A,
react: HASH_B,
start: HASH_A,
previous: null,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_A);
expect(result).toContain(HASH_B);
});
test("thread-step: refs() includes previous when non-null", async () => {
const store = createMemoryStore();
const { threadStep } = await registerWorkflowSchemas(store);
const HASH_C = "CCCCCCCCCCCCC";
const h = await store.put(threadStep, {
role: "r",
meta: {},
content: HASH_A,
react: HASH_B,
start: HASH_A,
previous: HASH_C,
});
const node = store.get(h) as CasNode;
const result = refs(store, node);
expect(result).toContain(HASH_C);
});
test("react-session: refs() returns the agent cas_ref", async () => {
const store = createMemoryStore();
const { reactSession } = await registerWorkflowSchemas(store);
const h = await store.put(reactSession, {
agent: HASH_A,
role: "r",
turns: [],
totalTokens: 0,
durationMs: 0,
});
const node = store.get(h) as CasNode;
expect(refs(store, node)).toContain(HASH_A);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 6: walk() — BFS traversal through linked workflow nodes
// ─────────────────────────────────────────────────────────────────────────────
describe("walk – cross-schema traversal", () => {
test("walk visits content node linked from thread-end", async () => {
const store = createMemoryStore();
const { threadEnd, content } = await registerWorkflowSchemas(store);
const contentHash = await store.put(content, { text: "summary text" });
const endHash = await store.put(threadEnd, {
returnCode: 0,
summary: "done",
start: contentHash,
lastStep: contentHash,
});
const visited = new Set<string>();
walk(store, endHash, (h) => visited.add(h));
expect(visited.has(endHash)).toBe(true);
expect(visited.has(contentHash)).toBe(true);
});
test("walk through role → (schema stored in store)", async () => {
const store = createMemoryStore();
const { role, roleSchema } = await registerWorkflowSchemas(store);
const schemaDocHash = await store.put(roleSchema, {
type: "object",
properties: { answer: { type: "string" } },
});
const roleHash = await store.put(role, {
name: "analyst",
description: "d",
systemPrompt: "s",
extractPrompt: "e",
schema: schemaDocHash,
});
const visited = new Set<string>();
walk(store, roleHash, (h) => visited.add(h));
expect(visited.has(roleHash)).toBe(true);
expect(visited.has(schemaDocHash)).toBe(true);
});
test("walk handles diamond: two thread-end nodes sharing the same start", async () => {
const store = createMemoryStore();
const { threadEnd, content } = await registerWorkflowSchemas(store);
const sharedStart = await store.put(content, { text: "start" });
const step1 = await store.put(content, { text: "step1" });
const step2 = await store.put(content, { text: "step2" });
const end1 = await store.put(threadEnd, {
returnCode: 0,
summary: "path A",
start: sharedStart,
lastStep: step1,
});
const end2 = await store.put(threadEnd, {
returnCode: 1,
summary: "path B",
start: sharedStart,
lastStep: step2,
});
// Use react-turn as the root linking both ends via input/output
const { reactTurn } = await registerWorkflowSchemas(store);
const turnHash = await store.put(reactTurn, {
input: end1,
output: end2,
toolCalls: [],
tokens: { input: 10, output: 5 },
latencyMs: 50,
});
const visited = new Set<string>();
walk(store, turnHash, (h) => visited.add(h));
expect(visited.has(turnHash)).toBe(true);
expect(visited.has(end1)).toBe(true);
expect(visited.has(end2)).toBe(true);
// sharedStart is reached from both end1 and end2, but visited only once
expect(visited.has(sharedStart)).toBe(true);
expect(visited.has(step1)).toBe(true);
expect(visited.has(step2)).toBe(true);
});
test("walk visits react-tool-call linked from react-turn", async () => {
const store = createMemoryStore();
const { reactTurn, reactToolCall, content } =
await registerWorkflowSchemas(store);
const argsHash = await store.put(content, { text: '{"q":"test"}' });
const resultHash = await store.put(content, { text: '{"r":"ok"}' });
const toolCallHash = await store.put(reactToolCall, {
name: "search",
arguments: argsHash,
result: resultHash,
durationMs: 120,
});
const inputHash = await store.put(content, { text: "input" });
const outputHash = await store.put(content, { text: "output" });
const turnHash = await store.put(reactTurn, {
input: inputHash,
output: outputHash,
toolCalls: [],
tokens: { input: 80, output: 40 },
latencyMs: 600,
});
const visited = new Set<string>();
walk(store, turnHash, (h) => visited.add(h));
expect(visited.has(turnHash)).toBe(true);
expect(visited.has(inputHash)).toBe(true);
expect(visited.has(outputHash)).toBe(true);
// toolCallHash is not in the turn's cas_ref fields (toolCalls array), only linked manually
expect(visited.has(toolCallHash)).toBe(false);
// walk from toolCallHash to verify it reaches args and result
const tcVisited = new Set<string>();
walk(store, toolCallHash, (h) => tcVisited.add(h));
expect(tcVisited.has(toolCallHash)).toBe(true);
expect(tcVisited.has(argsHash)).toBe(true);
expect(tcVisited.has(resultHash)).toBe(true);
});
});
-19
View File
@@ -1,19 +0,0 @@
export {
registerWorkflowSchemas,
type WorkflowSchemaHashes,
} from "./schemas.js";
export type {
AgentPayload,
ContentPayload,
ReactSessionPayload,
ReactToolCallPayload,
ReactTurnPayload,
ReactTurnTokens,
RolePayload,
RoleSchemaPayload,
ThreadEndPayload,
ThreadStartPayload,
ThreadStepPayload,
WorkflowPayload,
WorkflowTransition,
} from "./types.js";
-236
View File
@@ -1,236 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { type JSONSchema, putSchema } from "@uncaged/json-cas";
// ── Definition layer ──────────────────────────────────────────────────────────
const AGENT: JSONSchema = {
type: "object",
required: ["package", "version", "config"],
properties: {
package: { type: "string" },
version: { type: "string" },
config: { type: "object" },
},
additionalProperties: false,
};
/** role-schema nodes hold raw JSON Schema documents, so any object is valid. */
const ROLE_SCHEMA: JSONSchema = {
type: "object",
};
const ROLE: JSONSchema = {
type: "object",
required: ["name", "description", "systemPrompt", "extractPrompt", "schema"],
properties: {
name: { type: "string" },
description: { type: "string" },
systemPrompt: { type: "string" },
extractPrompt: { type: "string" },
schema: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
const WORKFLOW: JSONSchema = {
type: "object",
required: ["name", "description", "roles", "moderator"],
properties: {
name: { type: "string" },
description: { type: "string" },
roles: {
type: "object",
additionalProperties: { type: "string", format: "cas_ref" },
},
moderator: {
type: "array",
items: {
type: "object",
required: ["from", "to", "when"],
properties: {
from: { type: "string" },
to: { type: "string" },
when: { anyOf: [{ type: "string" }, { type: "null" }] },
},
additionalProperties: false,
},
},
},
additionalProperties: false,
};
// ── Execution layer ───────────────────────────────────────────────────────────
const THREAD_START: JSONSchema = {
type: "object",
required: ["workflow", "input", "depth", "parentThread", "agents"],
properties: {
workflow: { type: "string", format: "cas_ref" },
input: { type: "string" },
depth: { type: "number" },
parentThread: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
agents: {
type: "object",
additionalProperties: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
const THREAD_STEP: JSONSchema = {
type: "object",
required: ["role", "meta", "content", "react", "start", "previous"],
properties: {
role: { type: "string" },
meta: { type: "object" },
content: { type: "string", format: "cas_ref" },
react: { type: "string", format: "cas_ref" },
start: { type: "string", format: "cas_ref" },
previous: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
additionalProperties: false,
};
const THREAD_END: JSONSchema = {
type: "object",
required: ["returnCode", "summary", "start", "lastStep"],
properties: {
returnCode: { type: "number" },
summary: { type: "string" },
start: { type: "string", format: "cas_ref" },
lastStep: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
const CONTENT: JSONSchema = {
type: "object",
required: ["text"],
properties: {
text: { type: "string" },
},
additionalProperties: false,
};
// ── React layer ───────────────────────────────────────────────────────────────
const REACT_SESSION: JSONSchema = {
type: "object",
required: ["agent", "role", "turns", "totalTokens", "durationMs"],
properties: {
agent: { type: "string", format: "cas_ref" },
role: { type: "string" },
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
totalTokens: { type: "number" },
durationMs: { type: "number" },
},
additionalProperties: false,
};
const REACT_TURN: JSONSchema = {
type: "object",
required: ["input", "output", "toolCalls", "tokens", "latencyMs"],
properties: {
input: { type: "string", format: "cas_ref" },
output: { type: "string", format: "cas_ref" },
toolCalls: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
tokens: {
type: "object",
required: ["input", "output"],
properties: {
input: { type: "number" },
output: { type: "number" },
},
additionalProperties: false,
},
latencyMs: { type: "number" },
},
additionalProperties: false,
};
const REACT_TOOL_CALL: JSONSchema = {
type: "object",
required: ["name", "arguments", "result", "durationMs"],
properties: {
name: { type: "string" },
arguments: { type: "string", format: "cas_ref" },
result: { type: "string", format: "cas_ref" },
durationMs: { type: "number" },
},
additionalProperties: false,
};
// ── Registry ──────────────────────────────────────────────────────────────────
export type WorkflowSchemaHashes = {
agent: Hash;
roleSchema: Hash;
role: Hash;
workflow: Hash;
threadStart: Hash;
threadStep: Hash;
threadEnd: Hash;
content: Hash;
reactSession: Hash;
reactTurn: Hash;
reactToolCall: Hash;
};
/**
* Register all 11 workflow schemas into the given store.
* Returns a map from camelCase schema name to its CAS type hash.
* Idempotent: safe to call multiple times on the same store.
*/
export async function registerWorkflowSchemas(
store: Store,
): Promise<WorkflowSchemaHashes> {
const [
agent,
roleSchema,
role,
workflow,
threadStart,
threadStep,
threadEnd,
content,
reactSession,
reactTurn,
reactToolCall,
] = await Promise.all([
putSchema(store, AGENT),
putSchema(store, ROLE_SCHEMA),
putSchema(store, ROLE),
putSchema(store, WORKFLOW),
putSchema(store, THREAD_START),
putSchema(store, THREAD_STEP),
putSchema(store, THREAD_END),
putSchema(store, CONTENT),
putSchema(store, REACT_SESSION),
putSchema(store, REACT_TURN),
putSchema(store, REACT_TOOL_CALL),
]);
return {
agent,
roleSchema,
role,
workflow,
threadStart,
threadStep,
threadEnd,
content,
reactSession,
reactTurn,
reactToolCall,
};
}
-111
View File
@@ -1,111 +0,0 @@
import type { Hash } from "@uncaged/json-cas";
// ── Definition layer ──────────────────────────────────────────────────────────
export type AgentPayload = {
package: string;
version: string;
config: Record<string, unknown>;
};
/** A JSON Schema document stored as-is. */
export type RoleSchemaPayload = Record<string, unknown>;
export type RolePayload = {
name: string;
description: string;
systemPrompt: string;
extractPrompt: string;
/** cas_ref → role-schema */
schema: Hash;
};
export type WorkflowTransition = {
from: string;
to: string;
when: string | null;
};
export type WorkflowPayload = {
name: string;
description: string;
/** cas_ref → role */
roles: Record<string, Hash>;
moderator: WorkflowTransition[];
};
// ── Execution layer ───────────────────────────────────────────────────────────
export type ThreadStartPayload = {
/** cas_ref → workflow */
workflow: Hash;
input: string;
depth: number;
/** cas_ref → thread-start | null */
parentThread: Hash | null;
/** cas_ref → agent */
agents: Record<string, Hash>;
};
export type ThreadStepPayload = {
role: string;
meta: Record<string, unknown>;
/** cas_ref → content */
content: Hash;
/** cas_ref → react-session */
react: Hash;
/** cas_ref → thread-start */
start: Hash;
/** cas_ref → thread-step | null */
previous: Hash | null;
};
export type ThreadEndPayload = {
returnCode: number;
summary: string;
/** cas_ref → thread-start */
start: Hash;
/** cas_ref → thread-step */
lastStep: Hash;
};
export type ContentPayload = {
text: string;
};
// ── React layer ───────────────────────────────────────────────────────────────
export type ReactSessionPayload = {
/** cas_ref → agent */
agent: Hash;
role: string;
/** cas_ref → react-turn */
turns: Hash[];
totalTokens: number;
durationMs: number;
};
export type ReactTurnTokens = {
input: number;
output: number;
};
export type ReactTurnPayload = {
/** cas_ref → content */
input: Hash;
/** cas_ref → content */
output: Hash;
/** cas_ref → react-tool-call */
toolCalls: Hash[];
tokens: ReactTurnTokens;
latencyMs: number;
};
export type ReactToolCallPayload = {
name: string;
/** cas_ref → content (arguments) */
arguments: Hash;
/** cas_ref → content (result) */
result: Hash;
durationMs: number;
};
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"],
"references": [{ "path": "../json-cas" }]
}
+10
View File
@@ -1,5 +1,15 @@
# @uncaged/json-cas
## 0.5.3
### Patch Changes
- feat: add oneOf support to meta-schema validation
Added `oneOf` to `ALLOWED_SCHEMA_KEYS` and corresponding validation logic
in `isValidSchema`. This enables workflow frontmatter schemas that use
`oneOf` discriminated unions for multi-exit role definitions.
## 0.3.0
### Minor Changes
+159
View File
@@ -0,0 +1,159 @@
# @uncaged/json-cas
Core CAS engine — hashing, schema, store, verify, bootstrap.
## Overview
`@uncaged/json-cas` is the foundation of the json-cas monorepo. It defines content-addressed nodes (`CasNode`), the `Store` interface, XXH64-based hashing with deterministic CBOR, JSON Schema registration and validation (including `cas_ref` links between nodes), bootstrap seeding, and integrity verification.
Other packages build on this layer: `json-cas-fs` provides persistence, and `cli-json-cas` exposes store operations on the command line.
**Dependencies:** `ajv`, `cborg`, `xxhash-wasm`
## Installation
```bash
bun add @uncaged/json-cas
```
## API
All symbols below are exported from `src/index.ts`.
### Types
```typescript
/** 13-character uppercase Crockford Base32 (XXH64) */
type Hash = string;
type CasNode<T = unknown> = {
type: Hash;
payload: T;
timestamp: number; // Unix epoch ms
};
type Store = {
put(typeHash: Hash, payload: unknown): Promise<Hash>;
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
listByType(typeHash: Hash): Hash[];
};
type JSONSchema = Record<string, unknown>;
type BootstrapCapableStore = Store & {
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash>;
};
```
### Hashing
```typescript
function computeHash(typeHash: Hash, payload: unknown): Promise<Hash>;
function computeSelfHash(payload: unknown): Promise<Hash>;
function cborEncode(value: unknown): Uint8Array;
```
`computeHash``XXH64(utf8(typeHash) ++ CBOR(payload))` for normal nodes.
`computeSelfHash``XXH64(CBOR(payload))` for bootstrap nodes where `type === hash`.
### Bootstrap
```typescript
const BOOTSTRAP_STORE: unique symbol;
async function bootstrap(store: Store): Promise<Hash>;
```
Writes the meta-schema seed node (idempotent). Requires a `BootstrapCapableStore` (e.g. from `createMemoryStore()`).
### Schema
```typescript
class SchemaValidationError extends Error;
async function putSchema(store: Store, jsonSchema: JSONSchema): Promise<Hash>;
function getSchema(store: Store, typeHash: Hash): JSONSchema | null;
function validate(store: Store, node: CasNode): boolean;
function refs(store: Store, node: CasNode): Hash[];
function walk(
store: Store,
rootHash: Hash,
visitor: (hash: Hash, node: CasNode) => void,
): void;
```
- `putSchema` — stores a schema typed by the meta-schema; returned hash is the `typeHash` for conforming payloads.
- `refs` — collects all `format: "cas_ref"` values in the payload per schema shape.
- `walk` — BFS from `rootHash`, following `cas_ref` edges; cycles are visited once.
### Store
```typescript
function createMemoryStore(): BootstrapCapableStore;
```
In-memory `Store` with type indexing, suitable for tests and ephemeral use.
### Verify
```typescript
async function verify(hash: Hash, node: CasNode): Promise<boolean>;
```
Recomputes hash from `node` and compares to `hash` (self-referencing vs normal rules).
### Example
```typescript
import {
bootstrap,
createMemoryStore,
putSchema,
refs,
validate,
walk,
} from "@uncaged/json-cas";
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const personType = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
friend: { type: "string", format: "cas_ref" },
},
required: ["name"],
additionalProperties: false,
});
const aliceHash = await store.put(personType, { name: "Alice" });
const bobHash = await store.put(personType, {
name: "Bob",
friend: aliceHash,
});
const bob = store.get(bobHash)!;
console.log(validate(store, bob)); // true
console.log(refs(store, bob)); // [aliceHash]
walk(store, bobHash, (h) => console.log(h)); // bobHash, aliceHash
```
## Internal Structure
| File | Purpose |
|------|---------|
| `types.ts` | `Hash`, `CasNode`, `Store` |
| `hash.ts` | `computeHash`, `computeSelfHash` |
| `cbor.ts` | Deterministic CBOR encoding |
| `bootstrap-capable.ts` | `BOOTSTRAP_STORE` symbol and capability check |
| `bootstrap.ts` | Meta-schema seed and `bootstrap()` |
| `store.ts` | `createMemoryStore()` |
| `mem-store.ts` | Alternate in-memory store (tests only; not exported) |
| `schema.ts` | Schema put/get/validate, `refs`, `walk` |
| `verify.ts` | Node integrity verification |
| `index.ts` | Public exports |
Tests live in `src/*.test.ts` and `tests/`.
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas",
"version": "0.5.0",
"version": "0.5.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -15,11 +15,13 @@
"src"
],
"scripts": {
"test": "bun test"
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
},
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
"liquidjs": "^10.27.0",
"xxhash-wasm": "^1.1.0"
}
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { getSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
// ──────────────────────────────────────────────────────────────────────────────
// Built-in Schema Registration Tests
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
// Should return object with 6 aliases
expect(builtinSchemas).toHaveProperty("@schema");
expect(builtinSchemas).toHaveProperty("@string");
expect(builtinSchemas).toHaveProperty("@number");
expect(builtinSchemas).toHaveProperty("@object");
expect(builtinSchemas).toHaveProperty("@array");
expect(builtinSchemas).toHaveProperty("@bool");
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
expect(typeof hash).toBe("string");
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("should register @schema as meta-schema alias", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (!metaHash) throw new Error("@schema not found");
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
expect(metaSchema?.type).toBe("object");
expect(metaSchema?.description).toBe("json-cas JSON Schema meta-schema");
});
test("should register @string schema correctly", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const stringHash = builtinSchemas["@string"];
if (!stringHash) throw new Error("@string not found");
const stringSchema = getSchema(store, stringHash);
expect(stringSchema).toEqual({ type: "string" });
});
test("should register @number schema correctly", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const numberHash = builtinSchemas["@number"];
if (!numberHash) throw new Error("@number not found");
const numberSchema = getSchema(store, numberHash);
expect(numberSchema).toEqual({ type: "number" });
});
test("should register @object schema correctly", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const objectHash = builtinSchemas["@object"];
if (!objectHash) throw new Error("@object not found");
const objectSchema = getSchema(store, objectHash);
expect(objectSchema).toEqual({ type: "object" });
});
test("should register @array schema correctly", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const arrayHash = builtinSchemas["@array"];
if (!arrayHash) throw new Error("@array not found");
const arraySchema = getSchema(store, arrayHash);
expect(arraySchema).toEqual({ type: "array" });
});
test("should register @bool schema correctly", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const boolHash = builtinSchemas["@bool"];
if (!boolHash) throw new Error("@bool not found");
const boolSchema = getSchema(store, boolHash);
expect(boolSchema).toEqual({ type: "boolean" });
});
test("should return same hashes on repeated bootstrap calls", async () => {
const store = createMemoryStore();
const first = await bootstrap(store);
const second = await bootstrap(store);
expect(first).toEqual(second);
// Verify each alias points to same hash
expect(first["@string"]).toBe(second["@string"]);
expect(first["@number"]).toBe(second["@number"]);
expect(first["@object"]).toBe(second["@object"]);
expect(first["@array"]).toBe(second["@array"]);
expect(first["@bool"]).toBe(second["@bool"]);
expect(first["@schema"]).toBe(second["@schema"]);
});
test("all built-in schemas should be typed by meta-schema", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (!metaHash) throw new Error("@schema not found");
for (const [alias, hash] of Object.entries(builtinSchemas)) {
if (alias === "@schema") continue; // meta-schema is self-typed
const node = store.get(hash);
expect(node).not.toBeNull();
expect(node?.type).toBe(metaHash);
}
});
});
+28 -5
View File
@@ -50,6 +50,10 @@ const BOOTSTRAP_PAYLOAD = {
type: "array",
items: { type: "object", additionalProperties: false },
},
oneOf: {
type: "array",
items: { type: "object", additionalProperties: false },
},
items: { type: "object", additionalProperties: false },
format: { type: "string" },
title: { type: "string" },
@@ -60,13 +64,32 @@ const BOOTSTRAP_PAYLOAD = {
} as const;
/**
* Write the meta-schema seed node into the store.
* The returned hash equals the node's own type field (self-referencing).
* Idempotent: calling bootstrap multiple times returns the same hash.
* Write the meta-schema seed node into the store and register built-in schemas.
* The returned object contains aliases for the meta-schema and 5 primitive schemas.
* Idempotent: calling bootstrap multiple times returns the same hashes.
*/
export async function bootstrap(store: Store): Promise<Hash> {
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
if (!isBootstrapCapableStore(store)) {
throw new Error("Store does not support bootstrap");
}
return store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
// 1. Bootstrap the meta-schema (self-referential)
const metaHash = await store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
// 2. Register built-in primitive schemas directly (without putSchema to avoid recursion)
const stringHash = await store.put(metaHash, { type: "string" });
const numberHash = await store.put(metaHash, { type: "number" });
const objectHash = await store.put(metaHash, { type: "object" });
const arrayHash = await store.put(metaHash, { type: "array" });
const boolHash = await store.put(metaHash, { type: "boolean" });
// 3. Return map of aliases to hashes
return {
"@schema": metaHash,
"@string": stringHash,
"@number": numberHash,
"@object": objectHash,
"@array": arrayHash,
"@bool": boolHash,
};
}
+179
View File
@@ -0,0 +1,179 @@
import { afterEach, describe, expect, test } from "bun:test";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap } from "./bootstrap.js";
import { gc } from "./gc.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import { VariableStore } from "./variable-store.js";
const tmpDbPath = () =>
join(
tmpdir(),
`test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
);
describe("GC - Variable Model Refactoring", () => {
let store: Store;
let dbPath: string;
afterEach(() => {
try {
unlinkSync(dbPath);
} catch {
// Ignore cleanup errors
}
});
test("GC preserves variable-referenced nodes", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
const hashRef = await store.put(schemaHash, { name: "referenced" });
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("config", hashRef);
const stats = gc(store, varStore);
expect(store.has(hashRef)).toBe(true);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(1);
expect(stats.collected).toBeGreaterThanOrEqual(1);
varStore.close();
});
test("GC preserves nodes from variables with same name, different schemas", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaA = { type: "object", properties: { x: { type: "number" } } };
const schemaB = { type: "object", properties: { y: { type: "string" } } };
const schemaAHash = await putSchema(store, schemaA);
const schemaBHash = await putSchema(store, schemaB);
const hashA = await store.put(schemaAHash, { x: 42 });
const hashB = await store.put(schemaBHash, { y: "hello" });
const hashOrphan = await store.put(schemaAHash, { x: 99 });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("config", hashA);
varStore.set("config", hashB);
const stats = gc(store, varStore);
expect(store.has(hashA)).toBe(true);
expect(store.has(hashB)).toBe(true);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(2);
varStore.close();
});
test("GC removes nodes after variable deletion", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
const hashRef = await store.put(schemaHash, { name: "referenced" });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("config", hashRef);
varStore.remove("config", schemaHash);
const stats = gc(store, varStore);
expect(store.has(hashRef)).toBe(false);
expect(stats.scanned).toBe(0);
varStore.close();
});
test("GC is global across all variables", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaA = { type: "object", properties: { x: { type: "number" } } };
const schemaB = { type: "object", properties: { y: { type: "string" } } };
const schemaAHash = await putSchema(store, schemaA);
const schemaBHash = await putSchema(store, schemaB);
const hash1 = await store.put(schemaAHash, { x: 1 });
const hash2 = await store.put(schemaAHash, { x: 2 });
const hash3 = await store.put(schemaBHash, { y: "a" });
const hashOrphan = await store.put(schemaAHash, { x: 999 });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("uwf.thread", hash1);
varStore.set("uwf.workflow", hash2);
varStore.set("app.config", hash3);
const stats = gc(store, varStore);
expect(store.has(hash1)).toBe(true);
expect(store.has(hash2)).toBe(true);
expect(store.has(hash3)).toBe(true);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(3);
varStore.close();
});
test("GC integration with refactored variable store", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaA = { type: "object", properties: { x: { type: "number" } } };
const schemaB = { type: "object", properties: { y: { type: "string" } } };
const schemaAHash = await putSchema(store, schemaA);
const schemaBHash = await putSchema(store, schemaB);
const hashA1 = await store.put(schemaAHash, { x: 1 });
const hashA2 = await store.put(schemaAHash, { x: 2 });
const hashB = await store.put(schemaBHash, { y: "hello" });
const hashOrphan1 = await store.put(schemaAHash, { x: 999 });
const hashOrphan2 = await store.put(schemaBHash, { y: "orphan" });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// Create variables
varStore.set("var1", hashA1);
varStore.set("var2", hashA2);
varStore.set("var3", hashB);
// First GC: orphans removed
let stats = gc(store, varStore);
expect(store.has(hashA1)).toBe(true);
expect(store.has(hashA2)).toBe(true);
expect(store.has(hashB)).toBe(true);
expect(store.has(hashOrphan1)).toBe(false);
expect(store.has(hashOrphan2)).toBe(false);
expect(stats.scanned).toBe(3);
// Delete one variable
varStore.remove("var2", schemaAHash);
// Second GC: hashA2 removed
stats = gc(store, varStore);
expect(store.has(hashA1)).toBe(true);
expect(store.has(hashA2)).toBe(false);
expect(store.has(hashB)).toBe(true);
expect(stats.scanned).toBe(2);
varStore.close();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { walk } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
export interface GcStats {
total: number; // Total CAS nodes before GC
reachable: number; // Nodes marked as reachable
collected: number; // Nodes deleted (swept)
scanned: number; // Variables scanned as roots
}
/**
* Garbage collection: mark-and-sweep algorithm
* - Roots: all variable values (global, not scoped)
* - Mark: recursively walk refs from roots
* - Sweep: delete unmarked nodes
* - Schema preservation: schemas of reachable nodes are also marked
*/
export function gc(store: Store, varStore: VariableStore): GcStats {
// Get all variables (no filters → global)
const variables = varStore.list();
const scanned = variables.length;
// Collect unique root hashes from all variables
const roots = new Set<Hash>();
for (const variable of variables) {
roots.add(variable.value);
}
// Mark phase: walk from all roots
const reachable = new Set<Hash>();
for (const rootHash of roots) {
walk(store, rootHash, (hash, node) => {
// Mark the node itself
reachable.add(hash);
// Mark the schema (type) of the node
reachable.add(node.type);
});
}
// Walk the schema chain to ensure bootstrap meta-schema is preserved
// For each reachable schema, walk its schema chain (not its references)
const schemasToWalk = new Set<Hash>();
for (const hash of reachable) {
const node = store.get(hash);
if (node) {
schemasToWalk.add(node.type);
}
}
for (const schemaHash of schemasToWalk) {
// Walk the schema's type chain (meta-schema, etc.)
let current: Hash | null = schemaHash;
while (current !== null && !reachable.has(current)) {
reachable.add(current);
const node = store.get(current);
if (!node || node.type === current) {
// Self-referencing or missing node, stop
break;
}
current = node.type;
}
}
// Preserve all self-referencing nodes (bootstrap meta-schema)
// These are nodes where type === hash
const allHashes = store.listAll();
for (const hash of allHashes) {
const node = store.get(hash);
if (node && node.type === hash) {
reachable.add(hash);
}
}
// Count total nodes
const total = allHashes.length;
// Sweep phase: delete unmarked nodes
let collected = 0;
for (const hash of allHashes) {
if (!reachable.has(hash)) {
store.delete(hash);
collected++;
}
}
return {
total,
reachable: reachable.size,
collected,
scanned,
};
}
+43 -20
View File
@@ -197,9 +197,17 @@ describe("createMemoryStore – listByType", () => {
test("bootstrap node is listed under its self type", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const hash = builtinSchemas["@schema"] ?? "";
expect(store.listByType(hash)).toEqual([hash]);
// All built-in schemas should be typed by the meta-schema
const allTypedByMeta = store.listByType(hash);
expect(allTypedByMeta).toContain(hash); // meta-schema itself
expect(allTypedByMeta).toContain(builtinSchemas["@string"] ?? "");
expect(allTypedByMeta).toContain(builtinSchemas["@number"] ?? "");
expect(allTypedByMeta).toContain(builtinSchemas["@object"] ?? "");
expect(allTypedByMeta).toContain(builtinSchemas["@array"] ?? "");
expect(allTypedByMeta).toContain(builtinSchemas["@bool"] ?? "");
});
});
@@ -256,44 +264,59 @@ describe("bootstrap", () => {
);
});
test("returns a valid 13-char hash", async () => {
test("returns a map with 6 built-in schema aliases", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const builtinSchemas = await bootstrap(store);
expect(builtinSchemas).toHaveProperty("@schema");
expect(builtinSchemas).toHaveProperty("@string");
expect(builtinSchemas).toHaveProperty("@number");
expect(builtinSchemas).toHaveProperty("@object");
expect(builtinSchemas).toHaveProperty("@array");
expect(builtinSchemas).toHaveProperty("@bool");
// All values should be valid hashes
for (const hash of Object.values(builtinSchemas)) {
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("node is stored and retrievable", async () => {
test("meta-schema node is stored and retrievable", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
expect(store.has(hash)).toBe(true);
const node = store.get(hash);
expect(store.has(metaHash)).toBe(true);
const node = store.get(metaHash);
expect(node).not.toBeNull();
});
test("node is self-referencing: type === hash", async () => {
test("meta-schema node is self-referencing: type === hash", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
const node = store.get(hash) as CasNode;
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const node = store.get(metaHash) as CasNode;
expect(node.type).toBe(hash);
expect(node.type).toBe(metaHash);
});
test("bootstrap node passes verify()", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
const node = store.get(hash) as CasNode;
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const node = store.get(metaHash) as CasNode;
expect(await verify(hash, node)).toBe(true);
expect(await verify(metaHash, node)).toBe(true);
});
test("bootstrap is idempotent: same hash on repeated calls", async () => {
test("bootstrap is idempotent: same hashes on repeated calls", async () => {
const store = createMemoryStore();
const h1 = await bootstrap(store);
const h2 = await bootstrap(store);
expect(h1).toBe(h2);
expect(store.listByType(h1)).toHaveLength(1);
expect(h1).toEqual(h2);
// All 6 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
});
});
+14
View File
@@ -2,7 +2,10 @@ 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 { type GcStats, gc } from "./gc.js";
export { computeHash, computeSelfHash } from "./hash.js";
export { renderWithTemplate } from "./liquid-render.js";
export { type RenderOptions, render, renderAsync } from "./render.js";
export type { JSONSchema } from "./schema.js";
export {
getSchema,
@@ -14,4 +17,15 @@ export {
} from "./schema.js";
export { createMemoryStore } from "./store.js";
export type { CasNode, Hash, Store } from "./types.js";
export type { Variable } from "./variable.js";
export {
CasNodeNotFoundError,
createVariableStore,
InvalidTagFormatError,
InvalidVariableNameError,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
export { verify } from "./verify.js";
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
import { type Context, Liquid, type TagToken } from "liquidjs";
import { putSchema } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
export type RenderOptions = {
resolution?: number; // (0, 1], default 1.0
decay?: number; // (0, 1], default 0.5
epsilon?: number; // >= 0, default 0.01
};
const DEFAULT_RESOLUTION = 1.0;
const DEFAULT_DECAY = 0.5;
const DEFAULT_EPSILON = 0.01;
const FLOAT_TOLERANCE = 1e-10;
/**
* Render a CAS node using LiquidJS templates with resolution-based decay.
* Templates are discovered via variables: @ucas/template/text/<type-hash>
*/
export async function renderWithTemplate(
store: Store,
varStore: VariableStore,
hash: Hash,
options?: RenderOptions,
): Promise<string> {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
// Validate parameters
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
if (decay <= 0 || decay > 1) {
throw new Error("decay must be in (0, 1]");
}
if (epsilon < 0) {
throw new Error("epsilon must be >= 0");
}
const visited = new Set<Hash>();
// Create Liquid engine
const engine = createLiquidEngine(store, varStore, decay);
return await renderNode(
engine,
store,
varStore,
hash,
resolution,
decay,
epsilon,
visited,
);
}
/**
* Create a Liquid engine instance with custom render tag
*/
function createLiquidEngine(
store: Store,
varStore: VariableStore,
globalDecay: number,
): Liquid {
const engine = new Liquid({
strictFilters: false,
strictVariables: false,
});
// Type for storing parsed tag data
type RenderTagState = {
variable: string;
decay: number | undefined;
};
// Register custom {% render %} tag
// Capture store, varStore, globalDecay in closure
engine.registerTag("render", {
parse(token: TagToken) {
// Parse "variable" or "variable, decay: 0.7" syntax
const args = token.args.trim();
const match = args.match(/^(\S+)(?:,\s*decay:\s*([\d.]+))?$/);
if (!match) {
throw new Error(
`Invalid render tag syntax: ${args}. Expected: {% render variable %} or {% render variable, decay: 0.7 %}`,
);
}
// Store parsed values on the tag instance
const state = this as unknown as RenderTagState;
state.variable = match[1] as string;
state.decay = match[2] ? Number.parseFloat(match[2]) : undefined;
// Validate decay if provided
if (state.decay !== undefined) {
if (state.decay <= 0 || state.decay > 1) {
throw new Error("decay must be in (0, 1]");
}
}
},
async render(ctxLiquid: Context) {
// Access parsed values
const state = this as unknown as RenderTagState;
const variable = state.variable;
const explicitDecay = state.decay;
// Resolve the variable to a hash (split on dots for nested paths)
const variablePath = variable.split(".");
const value = ctxLiquid.get(variablePath);
// Handle null/undefined - render as empty
if (value === null || value === undefined) {
return "";
}
// Handle non-string values - render as empty
if (typeof value !== "string") {
return "";
}
const nodeHash = value as Hash;
// Get current render context
const currentResolution = ctxLiquid.get(["resolution"]) as number;
const currentEpsilon = ctxLiquid.get(["epsilon"]) as number;
// Compute child resolution using decay priority:
// 1. Template explicit decay (explicitDecay)
// 2. Global decay (from CLI/options)
// 3. Engine default (0.5)
const effectiveDecay =
explicitDecay !== undefined
? explicitDecay
: (globalDecay ?? DEFAULT_DECAY);
const childResolution = currentResolution * effectiveDecay;
// Recursively render the referenced node
const visited = ctxLiquid.get(["__visited"]) as Set<Hash>;
const output = await renderNode(
engine,
store,
varStore,
nodeHash,
childResolution,
globalDecay,
currentEpsilon,
visited,
);
return output;
},
});
return engine;
}
/**
* Render a single node with template or fallback to cas: reference
*/
async function renderNode(
engine: Liquid,
store: Store,
varStore: VariableStore,
hash: Hash,
currentResolution: number,
_globalDecay: number,
epsilon: number,
visited: Set<Hash>,
): Promise<string> {
// Check if resolution is below threshold
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
return `cas:${hash}`;
}
// Fetch the node
const node = store.get(hash);
if (node === null) {
return `cas:${hash}`;
}
// Cycle detection
if (visited.has(hash)) {
return `cas:${hash}`;
}
visited.add(hash);
try {
// Try to find a template for this node's type
const template = await findTemplate(store, varStore, node.type);
if (template === null) {
// No template found - this is handled by the caller (fallback to YAML)
// For now, return a simple representation
visited.delete(hash);
return renderFallback(store, node.payload);
}
// Render using the template
const context = {
resolution: currentResolution,
epsilon,
hash,
payload: node.payload,
type: node.type,
timestamp: node.timestamp,
__visited: visited, // Pass visited set through context
};
const output = await engine.parseAndRender(template, context);
visited.delete(hash);
return output;
} catch (error) {
visited.delete(hash);
throw error;
}
}
/**
* Find a template for a given type hash
*/
async function findTemplate(
store: Store,
varStore: VariableStore,
typeHash: Hash,
): Promise<string | null> {
const varName = `@ucas/template/text/${typeHash}`;
try {
// Find the string schema hash (we need this to query variables)
const stringSchema = await putSchema(store, { type: "string" });
const variable = varStore.get(varName, stringSchema);
if (variable === null) {
return null;
}
const templateNode = store.get(variable.value);
if (templateNode === null) {
return null;
}
// Template should be a string
if (typeof templateNode.payload !== "string") {
return null;
}
return templateNode.payload;
} catch {
return null;
}
}
/**
* Fallback renderer for nodes without templates
*/
function renderFallback(_store: Store, payload: unknown): string {
// Simple YAML-like representation
if (payload === null) {
return "null\n";
}
if (typeof payload === "string") {
return `${payload}\n`;
}
if (typeof payload === "number" || typeof payload === "boolean") {
return `${payload}\n`;
}
if (Array.isArray(payload)) {
if (payload.length === 0) {
return "[]\n";
}
return `- ${payload.join("\n- ")}\n`;
}
if (typeof payload === "object") {
const obj = payload as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) {
return "{}\n";
}
const pairs = keys.map((key) => `${key}: ${obj[key]}`);
return `${pairs.join("\n")}\n`;
}
return "null\n";
}
+8
View File
@@ -27,6 +27,14 @@ export class MemStore implements BootstrapCapableStore {
return this.#inner.listByType(typeHash);
}
listAll(): Hash[] {
return this.#inner.listAll();
}
delete(hash: Hash): void {
this.#inner.delete(hash);
}
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash> {
return this.#inner[BOOTSTRAP_STORE](payload);
}
+935
View File
@@ -0,0 +1,935 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { render } from "./render.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
import type { Hash } from "./types.js";
describe("Suite 1: Basic Rendering (No Nesting)", () => {
test("1.1 Render Simple Primitives", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, { type: "string" });
const hash = await store.put(textSchema, "hello");
const output = render(store, hash, { resolution: 1.0 });
expect(output).toContain("hello");
expect(output.trim()).toBeTruthy();
});
test("1.2 Render Object Node (Flat)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const objSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
count: { type: "number" },
},
});
const hash = await store.put(objSchema, { name: "test", count: 42 });
const output = render(store, hash, { resolution: 1.0 });
expect(output).toContain("name");
expect(output).toContain("test");
expect(output).toContain("count");
expect(output).toContain("42");
});
test("1.3 Render Array Node (Flat)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const arraySchema = await putSchema(store, {
type: "array",
items: { type: "number" },
});
const hash = await store.put(arraySchema, [1, 2, 3]);
const output = render(store, hash, { resolution: 1.0 });
expect(output).toContain("1");
expect(output).toContain("2");
expect(output).toContain("3");
});
test("1.4 Render with resolution=0 (Force Reference)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, { type: "string" });
const hash = await store.put(textSchema, "hello");
const output = render(store, hash, { resolution: 0 });
expect(output.trim()).toBe(`cas:${hash}`);
});
test("1.5 Render Non-existent Hash", () => {
const store = createMemoryStore();
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
// Non-existent node renders as cas: reference
const output = render(store, fakeHash);
expect(output.trim()).toBe(`cas:${fakeHash}`);
});
});
describe("Suite 2: Resolution Decay Model", () => {
test("2.1 Single-level Nesting with Default Decay", async () => {
const store = createMemoryStore();
await bootstrap(store);
const childSchema = await putSchema(store, {
type: "object",
properties: {
content: { type: "string" },
},
});
const childHash = await store.put(childSchema, { content: "leaf" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
title: { type: "string" },
child: { type: "string", format: "cas_ref" },
},
});
const parentHash = await store.put(parentSchema, {
title: "root",
child: childHash,
});
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("title");
expect(output).toContain("root");
expect(output).toContain("content");
expect(output).toContain("leaf");
});
test("2.2 Multi-level Nesting Reaches Epsilon", async () => {
const store = createMemoryStore();
await bootstrap(store);
const leafSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 8-level chain
let currentHash: Hash | null = null;
for (let i = 7; i >= 0; i--) {
currentHash = await store.put(leafSchema, {
value: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
// At depth 7: resolution = 0.5^7 = 0.0078125 <= 0.01
expect(output).toContain("value");
expect(output).toContain("0"); // root level
// Should contain cas: reference at deep level
expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
});
test("2.3 High Decay (Quick Cutoff)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
child: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 3-level nested structure
const level2Hash = await store.put(nodeSchema, { level: 2, child: null });
const level1Hash = await store.put(nodeSchema, {
level: 1,
child: level2Hash,
});
const rootHash = await store.put(nodeSchema, {
level: 0,
child: level1Hash,
});
const output = render(store, rootHash, {
resolution: 1.0,
decay: 0.1,
epsilon: 0.01,
});
expect(output).toContain("level");
expect(output).toContain("0"); // root
expect(output).toContain("1"); // level 1 (0.1 > 0.01)
// Level 2 should be reference (0.01 <= 0.01)
expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
});
test("2.4 Low Decay (Deep Expansion)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 10-level chain
let currentHash: Hash | null = null;
for (let i = 9; i >= 0; i--) {
currentHash = await store.put(nodeSchema, {
level: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 1.0,
decay: 0.9,
epsilon: 0.01,
});
// All 10 levels should be expanded (0.9^10 ≈ 0.349 > 0.01)
for (let i = 0; i < 10; i++) {
expect(output).toContain(`${i}`);
}
});
test("2.5 Starting Resolution Below 1.0", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 5-level chain
let currentHash: Hash | null = null;
for (let i = 4; i >= 0; i--) {
currentHash = await store.put(nodeSchema, {
level: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 0.5,
decay: 0.5,
epsilon: 0.01,
});
// resolution sequence: 0.5, 0.25, 0.125, 0.0625, 0.03125 (all > 0.01)
expect(output).toContain("0");
expect(output).toContain("1");
expect(output).toContain("2");
expect(output).toContain("3");
});
});
describe("Suite 3: Complex Graph Structures", () => {
test("3.1 Multiple Child References", async () => {
const store = createMemoryStore();
await bootstrap(store);
const itemSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
},
});
const item1 = await store.put(itemSchema, { name: "item1" });
const item2 = await store.put(itemSchema, { name: "item2" });
const item3 = await store.put(itemSchema, { name: "item3" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
items: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
});
const parentHash = await store.put(parentSchema, {
items: [item1, item2, item3],
});
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("item1");
expect(output).toContain("item2");
expect(output).toContain("item3");
});
test("3.2 Object with Multiple cas_ref Fields", async () => {
const store = createMemoryStore();
await bootstrap(store);
const childSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "string" },
},
});
const leftHash = await store.put(childSchema, { value: "left" });
const rightHash = await store.put(childSchema, { value: "right" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
left: { type: "string", format: "cas_ref" },
right: { type: "string", format: "cas_ref" },
data: { type: "string" },
},
});
const parentHash = await store.put(parentSchema, {
left: leftHash,
right: rightHash,
data: "node",
});
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("left");
expect(output).toContain("right");
expect(output).toContain("node");
});
test("3.3 Cycle Detection", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
ref: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
const hashA = await store.put(nodeSchema, { name: "A", ref: null });
const hashB = await store.put(nodeSchema, { name: "B", ref: hashA });
// Manually update A to reference B (simulate cycle)
// Note: In practice, this requires store manipulation
// For this test, we'll create a simpler case
const output = render(store, hashB, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
// Should not infinite loop
expect(output).toContain("B");
expect(output).toContain("A");
});
test("3.4 DAG (Shared Descendant)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const leafSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "string" },
},
});
const sharedLeaf = await store.put(leafSchema, { value: "shared" });
const branchSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
child: { type: "string", format: "cas_ref" },
},
});
const branchA = await store.put(branchSchema, {
name: "A",
child: sharedLeaf,
});
const branchB = await store.put(branchSchema, {
name: "B",
child: sharedLeaf,
});
const rootSchema = await putSchema(store, {
type: "object",
properties: {
left: { type: "string", format: "cas_ref" },
right: { type: "string", format: "cas_ref" },
},
});
const rootHash = await store.put(rootSchema, {
left: branchA,
right: branchB,
});
const output = render(store, rootHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("A");
expect(output).toContain("B");
expect(output).toContain("shared");
});
test("3.5 Deep Tree", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "number" },
left: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
right: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create binary tree (just 5 levels for test speed)
async function createTree(depth: number, value: number): Promise<Hash> {
if (depth === 0) {
return store.put(nodeSchema, { value, left: null, right: null });
}
const left = await createTree(depth - 1, value * 2);
const right = await createTree(depth - 1, value * 2 + 1);
return store.put(nodeSchema, { value, left, right });
}
const rootHash = await createTree(5, 1);
const output = render(store, rootHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
// Should complete without error
expect(output).toContain("value");
});
});
describe("Suite 4: Epsilon Boundary Cases", () => {
test("4.1 Resolution Exactly at Epsilon", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, { type: "string" });
const hash = await store.put(textSchema, "test");
const output = render(store, hash, {
resolution: 0.01,
decay: 0.5,
epsilon: 0.01,
});
expect(output.trim()).toBe(`cas:${hash}`);
});
test("4.2 Resolution Just Above Epsilon", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, { type: "string" });
const hash = await store.put(textSchema, "test");
const output = render(store, hash, {
resolution: 0.0100001,
epsilon: 0.01,
});
expect(output).toContain("test");
expect(output).not.toContain("cas:");
});
test("4.3 Very Small Epsilon (Deep Expansion)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 15-level chain
let currentHash: Hash | null = null;
for (let i = 14; i >= 0; i--) {
currentHash = await store.put(nodeSchema, {
level: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.000001,
});
// Many levels should be expanded
expect(output).toContain("0");
expect(output).toContain("5");
expect(output).toContain("10");
});
test("4.4 Zero Epsilon (Never Prune)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 20-level chain
let currentHash: Hash | null = null;
for (let i = 19; i >= 0; i--) {
currentHash = await store.put(nodeSchema, {
level: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0,
});
// All levels should be present
expect(output).toContain("0");
expect(output).toContain("10");
expect(output).toContain("19");
});
});
describe("Suite 5: YAML Output Format", () => {
test("5.1 Valid YAML Syntax", async () => {
const store = createMemoryStore();
await bootstrap(store);
const objSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
count: { type: "number" },
},
});
const hash = await store.put(objSchema, { name: "test", count: 42 });
const output = render(store, hash);
// Basic YAML validation - should have key: value pairs
expect(output).toMatch(/\w+:/);
});
test("5.2 Nested Object Indentation", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nestedSchema = await putSchema(store, {
type: "object",
properties: {
outer: {
type: "object",
properties: {
inner: { type: "string" },
},
},
},
});
const hash = await store.put(nestedSchema, {
outer: { inner: "value" },
});
const output = render(store, hash);
// Should have proper indentation (2 spaces)
expect(output).toContain("outer");
expect(output).toContain("inner");
expect(output).toContain("value");
});
test("5.3 Array Rendering", async () => {
const store = createMemoryStore();
await bootstrap(store);
const arraySchema = await putSchema(store, {
type: "array",
items: { type: "number" },
});
const hash = await store.put(arraySchema, [1, 2, 3]);
const output = render(store, hash);
// YAML array format
expect(output).toMatch(/[-[].*[1-3]/);
});
test("5.4 CAS Reference in YAML", async () => {
const store = createMemoryStore();
await bootstrap(store);
const childSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "string" },
},
});
const childHash = await store.put(childSchema, { value: "child" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
child: { type: "string", format: "cas_ref" },
},
});
const parentHash = await store.put(parentSchema, { child: childHash });
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.1,
epsilon: 0.5,
});
// Child should be rendered as cas: reference
expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
});
test("5.5 Special Characters Escaping", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, { type: "string" });
const hash = await store.put(textSchema, "line1\nline2: value");
const output = render(store, hash);
// Should handle newlines and colons
expect(output).toBeTruthy();
});
test("5.6 Null Handling", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nullableSchema = await putSchema(store, {
type: "object",
properties: {
ref: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
const hash = await store.put(nullableSchema, { ref: null });
const output = render(store, hash);
expect(output).toContain("null");
});
});
describe("Suite 6: Schema Integration", () => {
test("6.1 Detect cas_ref Fields via Schema", async () => {
const store = createMemoryStore();
await bootstrap(store);
const childSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "string" },
},
});
const childHash = await store.put(childSchema, { value: "child" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
link: { type: "string", format: "cas_ref" },
},
});
const parentHash = await store.put(parentSchema, { link: childHash });
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("child");
});
test("6.2 Non-cas_ref String Not Expanded", async () => {
const store = createMemoryStore();
await bootstrap(store);
const objSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
},
});
const hash = await store.put(objSchema, { name: "ABC123XYZ9012" });
const output = render(store, hash);
// Should be plain string, not expanded
expect(output).toContain("ABC123XYZ9012");
expect(output).not.toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
});
test("6.3 Array of cas_ref", async () => {
const store = createMemoryStore();
await bootstrap(store);
const itemSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
},
});
const item1 = await store.put(itemSchema, { name: "item1" });
const item2 = await store.put(itemSchema, { name: "item2" });
const arraySchema = await putSchema(store, {
type: "array",
items: { type: "string", format: "cas_ref" },
});
const arrayHash = await store.put(arraySchema, [item1, item2]);
const output = render(store, arrayHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("item1");
expect(output).toContain("item2");
});
test("6.4 anyOf with cas_ref (Nullable Reference)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const childSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "string" },
},
});
const childHash = await store.put(childSchema, { value: "child" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
ref: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
const parentHash = await store.put(parentSchema, { ref: childHash });
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("child");
});
test("6.5 Schema-less Node (Bootstrap Node)", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const output = render(store, metaHash);
// Should render without recursive expansion
expect(output).toBeTruthy();
});
});
describe("Suite 7: Error Handling", () => {
test("7.1 Missing Referenced Node", async () => {
const store = createMemoryStore();
await bootstrap(store);
const parentSchema = await putSchema(store, {
type: "object",
properties: {
child: { type: "string", format: "cas_ref" },
},
});
const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash;
const parentHash = await store.put(parentSchema, { child: fakeChildHash });
const output = render(store, parentHash);
// Should render missing ref as cas:<hash>
expect(output).toContain(`cas:${fakeChildHash}`);
});
test("7.3 Invalid Resolution Parameter", () => {
const store = createMemoryStore();
const fakeHash = "AAAAAAAAAAAAA" as Hash;
expect(() => render(store, fakeHash, { resolution: -1 })).toThrow();
});
test("7.4 Invalid Decay Parameter", () => {
const store = createMemoryStore();
const fakeHash = "AAAAAAAAAAAAA" as Hash;
expect(() => render(store, fakeHash, { decay: 1.5 })).toThrow();
});
test("7.5 Invalid Epsilon Parameter", () => {
const store = createMemoryStore();
const fakeHash = "AAAAAAAAAAAAA" as Hash;
expect(() => render(store, fakeHash, { epsilon: -0.01 })).toThrow();
});
});
describe("Suite 8: Performance & Edge Cases", () => {
test("8.1 Large Payload", async () => {
const store = createMemoryStore();
await bootstrap(store);
const arraySchema = await putSchema(store, {
type: "array",
items: {
type: "object",
properties: {
id: { type: "number" },
name: { type: "string" },
},
},
});
const largeArray = Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `item${i}`,
}));
const hash = await store.put(arraySchema, largeArray);
const start = Date.now();
const output = render(store, hash);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(5000);
expect(output).toBeTruthy();
});
test("8.2 Wide Fan-out", async () => {
const store = createMemoryStore();
await bootstrap(store);
const itemSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "number" },
},
});
const children: Hash[] = [];
for (let i = 0; i < 100; i++) {
const hash = await store.put(itemSchema, { value: i });
children.push(hash);
}
const parentSchema = await putSchema(store, {
type: "array",
items: { type: "string", format: "cas_ref" },
});
const parentHash = await store.put(parentSchema, children);
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBeTruthy();
});
test("8.3 Empty Payload", async () => {
const store = createMemoryStore();
await bootstrap(store);
const emptySchema = await putSchema(store, { type: "object" });
const hash = await store.put(emptySchema, {});
const output = render(store, hash);
expect(output.trim()).toMatch(/\{\}/);
});
test("8.4 Unicode in Payload", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, {
type: "object",
properties: {
text: { type: "string" },
},
});
const hash = await store.put(textSchema, { text: "你好世界 🌍" });
const output = render(store, hash);
expect(output).toContain("你好世界");
expect(output).toContain("🌍");
});
});
+294
View File
@@ -0,0 +1,294 @@
import { renderWithTemplate } from "./liquid-render.js";
import { putSchema, refs } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
export type RenderOptions = {
resolution?: number; // (0, 1], default 1.0
decay?: number; // (0, 1], default 0.5
epsilon?: number; // >= 0, default 0.01
varStore?: VariableStore; // Optional: for template lookup
};
const DEFAULT_RESOLUTION = 1.0;
const DEFAULT_DECAY = 0.5;
const DEFAULT_EPSILON = 0.01;
// Small tolerance for floating point comparison
const FLOAT_TOLERANCE = 1e-10;
/**
* Render a CAS node as YAML with resolution-based decay.
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
* This is the synchronous version without template support.
* For template support, use renderAsync() with varStore.
*/
export function render(
store: Store,
hash: Hash,
options?: RenderOptions,
): string {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
// Validate parameters
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
if (decay <= 0 || decay > 1) {
throw new Error("decay must be in (0, 1]");
}
if (epsilon < 0) {
throw new Error("epsilon must be >= 0");
}
const visited = new Set<Hash>();
return renderNode(store, hash, resolution, decay, epsilon, visited);
}
/**
* Async render with LiquidJS template support.
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
* If varStore is provided, attempts to use LiquidJS templates first, fallback to YAML.
*/
export async function renderAsync(
store: Store,
hash: Hash,
options?: RenderOptions,
): Promise<string> {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
const varStore = options?.varStore;
// Validate parameters
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
if (decay <= 0 || decay > 1) {
throw new Error("decay must be in (0, 1]");
}
if (epsilon < 0) {
throw new Error("epsilon must be >= 0");
}
// If varStore provided, try template rendering first
if (varStore !== undefined) {
try {
const node = store.get(hash);
if (node !== null) {
// Check if a template exists for this type
const templateExists = await hasTemplate(store, varStore, node.type);
if (templateExists) {
return await renderWithTemplate(store, varStore, hash, {
resolution,
decay,
epsilon,
});
}
}
} catch {
// Fall through to YAML rendering
}
}
// Fallback to YAML rendering
const visited = new Set<Hash>();
return renderNode(store, hash, resolution, decay, epsilon, visited);
}
/**
* Check if a template exists for a given type
*/
async function hasTemplate(
store: Store,
varStore: VariableStore,
typeHash: Hash,
): Promise<boolean> {
const varName = `@ucas/template/text/${typeHash}`;
try {
const stringSchema = await putSchema(store, { type: "string" });
const variable = varStore.get(varName, stringSchema);
return variable !== null;
} catch {
return false;
}
}
function renderNode(
store: Store,
hash: Hash,
currentResolution: number,
decay: number,
epsilon: number,
visited: Set<Hash>,
): string {
// Check if resolution is below threshold (with floating point tolerance)
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
return `cas:${hash}`;
}
// Fetch the node
const node = store.get(hash);
if (node === null) {
// Missing node - render as cas: reference
return `cas:${hash}`;
}
// Cycle detection
if (visited.has(hash)) {
return `cas:${hash}`;
}
visited.add(hash);
// Get references from this node's schema
const nodeRefs = refs(store, node);
const refSet = new Set(nodeRefs);
// Calculate child resolution for next level
const childResolution = currentResolution * decay;
// Render the payload with recursive expansion of cas_ref fields
const rendered = renderValue(
store,
node.payload,
refSet,
childResolution,
decay,
epsilon,
visited,
);
visited.delete(hash);
return rendered;
}
function renderValue(
store: Store,
value: unknown,
refHashes: Set<Hash>,
childResolution: number,
decay: number,
epsilon: number,
visited: Set<Hash>,
): string {
// Handle null
if (value === null) {
return "null\n";
}
// Handle primitives
if (typeof value === "string") {
// Check if this string is a cas_ref
if (refHashes.has(value as Hash)) {
// Recursively render the referenced node
return renderNode(
store,
value as Hash,
childResolution,
decay,
epsilon,
visited,
);
}
// Otherwise, render as YAML string
return toYamlString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return `${value}\n`;
}
// Handle arrays
if (Array.isArray(value)) {
if (value.length === 0) {
return "[]\n";
}
const items = value.map((item) => {
const itemYaml = renderValue(
store,
item,
refHashes,
childResolution,
decay,
epsilon,
visited,
);
return indent(itemYaml.trim(), 2);
});
return `- ${items.join("\n- ")}\n`;
}
// Handle objects
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) {
return "{}\n";
}
const pairs = keys.map((key) => {
const val = obj[key];
const valYaml = renderValue(
store,
val,
refHashes,
childResolution,
decay,
epsilon,
visited,
);
const trimmedVal = valYaml.trim();
// If value is multiline, indent it
if (trimmedVal.includes("\n")) {
return `${key}:\n${indent(trimmedVal, 2)}`;
}
return `${key}: ${trimmedVal}`;
});
return `${pairs.join("\n")}\n`;
}
return "null\n";
}
function toYamlString(str: string): string {
// Handle special characters
if (
str.includes("\n") ||
str.includes(":") ||
str.includes("#") ||
str.includes("[") ||
str.includes("]") ||
str.includes("{") ||
str.includes("}") ||
str.includes("'") ||
str.includes('"') ||
str.startsWith(" ") ||
str.endsWith(" ")
) {
// Use double-quoted string with escaping
const escaped = str
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n");
return `"${escaped}"\n`;
}
return `${str}\n`;
}
function indent(text: string, spaces: number): string {
const prefix = " ".repeat(spaces);
return text
.split("\n")
.map((line) => (line ? prefix + line : line))
.join("\n");
}
+10 -5
View File
@@ -29,7 +29,8 @@ describe("putSchema", () => {
test("schema node type equals the meta-schema hash", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const schemaHash = await putSchema(store, { type: "string" });
const node = store.get(schemaHash) as CasNode;
@@ -355,7 +356,8 @@ describe("walk", () => {
describe("bootstrap meta-schema self-reference", () => {
test("metaNode.type === metaHash (self-referencing)", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaNode = store.get(metaHash) as CasNode;
expect(metaNode.type).toBe(metaHash);
@@ -363,7 +365,8 @@ describe("bootstrap meta-schema self-reference", () => {
test("schema nodes have type === metaHash", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const schemaHash = await putSchema(store, { type: "string" });
const schemaNode = store.get(schemaHash) as CasNode;
@@ -372,7 +375,8 @@ describe("bootstrap meta-schema self-reference", () => {
test("data nodes have type === schemaHash (not metaHash)", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const schemaHash = await putSchema(store, {
type: "object",
properties: { val: { type: "number" } },
@@ -386,7 +390,8 @@ describe("bootstrap meta-schema self-reference", () => {
test("bootstrap is idempotent across putSchema calls", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
await putSchema(store, { type: "string" });
await putSchema(store, { type: "number" });
+13 -1
View File
@@ -28,6 +28,7 @@ const ALLOWED_SCHEMA_KEYS = new Set([
"required",
"additionalProperties",
"anyOf",
"oneOf",
"items",
"format",
"title",
@@ -108,6 +109,13 @@ function isValidSchema(value: unknown): boolean {
}
}
if ("oneOf" in schema) {
if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) return false;
for (const entry of schema.oneOf) {
if (!isValidSchema(entry)) return false;
}
}
if ("items" in schema && !isValidSchema(schema.items)) return false;
if ("format" in schema && typeof schema.format !== "string") return false;
if ("title" in schema && typeof schema.title !== "string") return false;
@@ -134,7 +142,11 @@ export async function putSchema(
store: Store,
jsonSchema: JSONSchema,
): Promise<Hash> {
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (!metaHash) {
throw new Error("Meta-schema not found in bootstrap result");
}
if (!isValidSchema(jsonSchema)) {
throw new SchemaValidationError(
"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema",
+19
View File
@@ -52,6 +52,25 @@ export function createMemoryStore(): BootstrapCapableStore {
return set ? [...set] : [];
},
listAll(): Hash[] {
return Array.from(data.keys());
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
data.delete(hash);
// Remove from type index
const set = byType.get(node.type);
if (set) {
set.delete(hash);
if (set.size === 0) {
byType.delete(node.type);
}
}
}
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
+2
View File
@@ -24,4 +24,6 @@ export type Store = {
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
listByType(typeHash: Hash): Hash[];
listAll(): Hash[];
delete(hash: Hash): void;
};
File diff suppressed because it is too large Load Diff
+723
View File
@@ -0,0 +1,723 @@
import { Database } from "bun:sqlite";
import type { Hash, Store } from "./types.js";
import type { Variable } from "./variable.js";
/**
* Custom error types for variable operations
*/
export class VariableNotFoundError extends Error {
constructor(
public variableName: string,
public variableSchema: Hash,
) {
super(`Variable not found: name=${variableName}, schema=${variableSchema}`);
this.name = "VariableNotFoundError";
}
}
export class InvalidVariableNameError extends Error {
constructor(
public variableName: string,
public reason: string,
) {
super(`Invalid variable name "${variableName}": ${reason}`);
this.name = "InvalidVariableNameError";
}
}
export class SchemaMismatchError extends Error {
constructor(
public expected: string,
public actual: string,
) {
super(`Schema mismatch: expected ${expected}, got ${actual}`);
this.name = "SchemaMismatchError";
}
}
export class CasNodeNotFoundError extends Error {
constructor(hash: string) {
super(`CAS node not found: ${hash}`);
this.name = "CasNodeNotFoundError";
}
}
export class TagLabelConflictError extends Error {
constructor(
public conflictName: string,
public existingType: "tag" | "label",
public attemptedType: "tag" | "label",
) {
super(`Conflict: '${conflictName}' already exists as a ${existingType}`);
this.name = "TagLabelConflictError";
}
}
export class InvalidTagFormatError extends Error {
constructor(tag: string) {
super(`Invalid tag format: ${tag}`);
this.name = "InvalidTagFormatError";
}
}
/**
* Variable store with SQLite backend
*/
export class VariableStore {
private db: Database;
constructor(
dbPath: string,
private casStore: Store,
) {
this.db = new Database(dbPath, { create: true });
// Enable foreign keys
this.db.exec("PRAGMA foreign_keys = ON");
this.initDb();
}
private initDb(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS variables (
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
PRIMARY KEY (name, schema)
);
CREATE INDEX IF NOT EXISTS idx_var_name ON variables(name);
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
CREATE TABLE IF NOT EXISTS variable_tags (
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (variable_name, variable_schema, key),
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS variable_labels (
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (variable_name, variable_schema, name),
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key);
CREATE INDEX IF NOT EXISTS idx_var_tag_key_value ON variable_tags(key, value);
CREATE INDEX IF NOT EXISTS idx_var_label_name ON variable_labels(name);
`);
}
/**
* Validate variable name format
* @ is allowed at the start of the first segment (system-reserved)
*/
private validateName(name: string): void {
// Rule 1: Cannot be empty
if (name === "") {
throw new InvalidVariableNameError(name, "Name cannot be empty");
}
// Rule 2: No leading slash
if (name.startsWith("/")) {
throw new InvalidVariableNameError(
name,
"Name cannot start with leading slash",
);
}
// Rule 3: No trailing slash
if (name.endsWith("/")) {
throw new InvalidVariableNameError(
name,
"Name cannot end with trailing slash",
);
}
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment)
const segments = name.split("/");
for (let i = 0; i < segments.length; i++) {
const segment = segments[i] as string;
if (segment === "") {
throw new InvalidVariableNameError(
name,
"Name contains empty segment (consecutive slashes //)",
);
}
// Check for invalid characters
// First segment can start with @, all segments can contain [a-zA-Z0-9._-]
const regex = i === 0 ? /^@?[a-zA-Z0-9._-]+$/ : /^[a-zA-Z0-9._-]+$/;
if (!regex.test(segment)) {
throw new InvalidVariableNameError(
name,
`Segment "${segment}" contains invalid characters (only ${i === 0 ? "@, " : ""}a-z, A-Z, 0-9, ., _, - allowed)`,
);
}
}
}
/**
* 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;
}
/**
* Load tags for a variable
*/
private loadTags(name: string, schema: Hash): Record<string, string> {
const stmt = this.db.prepare(`
SELECT key, value
FROM variable_tags
WHERE variable_name = ? AND variable_schema = ?
`);
const rows = stmt.all(name, schema) as Array<{
key: string;
value: string;
}>;
const tags: Record<string, string> = {};
for (const row of rows) {
tags[row.key] = row.value;
}
return tags;
}
/**
* Load labels for a variable
*/
private loadLabels(name: string, schema: Hash): string[] {
const stmt = this.db.prepare(`
SELECT name
FROM variable_labels
WHERE variable_name = ? AND variable_schema = ?
ORDER BY name ASC
`);
const rows = stmt.all(name, schema) as Array<{ name: string }>;
return rows.map((row) => row.name);
}
/**
* Set a variable (upsert: create or update)
*/
set(
name: string,
value: string,
options?: {
tags?: Record<string, string>;
labels?: string[];
},
): Variable {
// Validate name format
this.validateName(name);
const schema = this.extractSchema(value);
// Check if variable exists
const existing = this.get(name, schema);
if (existing !== null) {
// Update existing variable
const now = Date.now();
// If options provided, use them; otherwise preserve existing
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
// Check for tag/label conflicts when updating with new options
if (options !== undefined) {
const tagKeys = Object.keys(tags);
for (const key of tagKeys) {
if (labels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
}
this.db.exec("BEGIN TRANSACTION");
try {
// Update value and timestamp
const updateStmt = this.db.prepare(`
UPDATE variables
SET value = ?, updated = ?
WHERE name = ? AND schema = ?
`);
updateStmt.run(value, now, name, schema);
// If options provided, update tags/labels
if (options !== undefined) {
// Delete existing tags and labels
this.db
.prepare(`
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ?
`)
.run(name, schema);
this.db
.prepare(`
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ?
`)
.run(name, schema);
// Insert new tags
const tagKeys = Object.keys(tags);
if (tagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, val] of Object.entries(tags)) {
tagStmt.run(name, schema, key, val);
}
}
// Insert new labels
if (labels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const labelName of labels) {
labelStmt.run(name, schema, labelName);
}
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
return {
name,
schema,
value,
created: existing.created,
updated: now,
tags,
labels: [...labels],
};
}
// Create new variable
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
// Check for tag/label conflicts
const tagKeys = Object.keys(tags);
for (const key of tagKeys) {
if (labels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
const now = Date.now();
this.db.exec("BEGIN TRANSACTION");
try {
const stmt = this.db.prepare(`
INSERT INTO variables (name, schema, value, created, updated)
VALUES (?, ?, ?, ?, ?)
`);
stmt.run(name, schema, value, now, now);
// Insert tags
if (tagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, val] of Object.entries(tags)) {
tagStmt.run(name, schema, key, val);
}
}
// Insert labels
if (labels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const labelName of labels) {
labelStmt.run(name, schema, labelName);
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
return {
name,
schema,
value,
created: now,
updated: now,
tags,
labels: [...labels],
};
}
/**
* Get a variable by name, optionally with schema
*/
/**
* Get a variable by name and schema
* @param name - Variable name
* @param schema - Schema hash (required)
* @returns Variable if found, null otherwise
*/
get(name: string, schema: Hash): Variable | null {
// Precise match with schema
const stmt = this.db.prepare(`
SELECT name, schema, value, created, updated
FROM variables
WHERE name = ? AND schema = ?
`);
const row = stmt.get(name, schema) as
| {
name: string;
schema: string;
value: string;
created: number;
updated: number;
}
| undefined
| null;
if (row === undefined || row === null) {
return null;
}
const tags = this.loadTags(row.name, row.schema);
const labels = this.loadLabels(row.name, row.schema);
return {
name: row.name,
schema: row.schema,
value: row.value,
created: row.created,
updated: row.updated,
tags,
labels,
};
}
/**
* Update a variable's value (with schema validation)
*/
update(name: string, schema: Hash, value: string): Variable {
// Validate name format
this.validateName(name);
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(name, schema);
}
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 name = ? AND schema = ?
`);
stmt.run(value, now, name, schema);
return {
...existing,
value,
updated: now,
};
}
/**
* Remove a variable (or all variants if schema omitted)
*/
remove(name: string): Variable[];
remove(name: string, schema: Hash): Variable;
remove(name: string, schema?: Hash): Variable | Variable[] {
if (schema !== undefined) {
// Remove specific (name, schema) variant
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(name, schema);
}
const stmt = this.db.prepare(`
DELETE FROM variables WHERE name = ? AND schema = ?
`);
stmt.run(name, schema);
return existing;
}
// Remove all schema variants for this name
const variants = this.list({ exactName: name });
if (variants.length === 0) {
return [];
}
const stmt = this.db.prepare(`
DELETE FROM variables WHERE name = ?
`);
stmt.run(name);
return variants;
}
/**
* List variables with optional filters
*/
list(options?: {
namePrefix?: string;
exactName?: string;
schema?: Hash;
tags?: Record<string, string>;
labels?: string[];
}): Variable[] {
// Validate mutually exclusive options
if (options?.namePrefix !== undefined && options?.exactName !== undefined) {
throw new Error(
"namePrefix and exactName are mutually exclusive - cannot specify both",
);
}
const namePrefix = options?.namePrefix ?? "";
const exactName = options?.exactName;
const schema = options?.schema;
const filterTags = options?.tags ?? {};
const filterLabels = options?.labels ?? [];
// Build query with filters
let query = `
SELECT DISTINCT v.name, v.schema, v.value, v.created, v.updated
FROM variables v
`;
const params: (string | number)[] = [];
// Tag filters (AND logic)
const tagKeys = Object.keys(filterTags);
for (let i = 0; i < tagKeys.length; i++) {
const key = tagKeys[i] as string;
const value = filterTags[key] as string;
query += `
INNER JOIN variable_tags t${i} ON v.name = t${i}.variable_name
AND v.schema = t${i}.variable_schema
AND t${i}.key = ? AND t${i}.value = ?
`;
params.push(key, value);
}
// Label filters (AND logic)
for (let i = 0; i < filterLabels.length; i++) {
const label = filterLabels[i] as string;
query += `
INNER JOIN variable_labels l${i} ON v.name = l${i}.variable_name
AND v.schema = l${i}.variable_schema
AND l${i}.name = ?
`;
params.push(label);
}
// WHERE clause for name filters and schema
const whereClauses: string[] = [];
if (exactName !== undefined) {
whereClauses.push("v.name = ?");
params.push(exactName);
} else if (namePrefix !== "") {
whereClauses.push("v.name LIKE ? || '%'");
params.push(namePrefix);
}
if (schema !== undefined) {
whereClauses.push("v.schema = ?");
params.push(schema);
}
if (whereClauses.length > 0) {
query += ` WHERE ${whereClauses.join(" AND ")}`;
}
query += " ORDER BY v.created ASC";
const stmt = this.db.prepare(query);
const rows = stmt.all(...params) as Array<{
name: string;
schema: string;
value: string;
created: number;
updated: number;
}>;
return rows.map((row) => ({
name: row.name,
schema: row.schema,
value: row.value,
created: row.created,
updated: row.updated,
tags: this.loadTags(row.name, row.schema),
labels: this.loadLabels(row.name, row.schema),
}));
}
/**
* Add/update/delete tags and labels
*/
tag(
name: string,
schema: Hash,
operations: {
add?: Record<string, string>; // tags to add/update
addLabels?: string[]; // labels to add
delete?: string[]; // tag keys or label names to delete
},
): Variable {
// Validate name format
this.validateName(name);
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(name, schema);
}
const addTags = operations.add ?? {};
const addLabels = operations.addLabels ?? [];
const deleteNames = operations.delete ?? [];
// Check for conflicts between tags and labels
const newTagKeys = Object.keys(addTags);
for (const key of newTagKeys) {
// Check if this key is being added as a label in the same operation
if (addLabels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
// Check if this key already exists as a label (and not being deleted)
if (existing.labels.includes(key) && !deleteNames.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
for (const labelName of addLabels) {
// Check if this name is being added as a tag in the same operation
if (newTagKeys.includes(labelName)) {
throw new TagLabelConflictError(labelName, "tag", "label");
}
// Check if this name already exists as a tag key (and not being deleted)
if (
existing.tags[labelName] !== undefined &&
!deleteNames.includes(labelName)
) {
throw new TagLabelConflictError(labelName, "tag", "label");
}
}
const now = Date.now();
this.db.exec("BEGIN TRANSACTION");
try {
// Update timestamp
const updateStmt = this.db.prepare(`
UPDATE variables SET updated = ? WHERE name = ? AND schema = ?
`);
updateStmt.run(now, name, schema);
// Delete tags and labels
if (deleteNames.length > 0) {
const deleteTagStmt = this.db.prepare(`
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ? AND key = ?
`);
const deleteLabelStmt = this.db.prepare(`
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ? AND name = ?
`);
for (const deleteName of deleteNames) {
deleteTagStmt.run(name, schema, deleteName);
deleteLabelStmt.run(name, schema, deleteName);
}
}
// Add or update tags
if (newTagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT OR REPLACE INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, value] of Object.entries(addTags)) {
tagStmt.run(name, schema, key, value);
}
}
// Add labels (with conflict handling)
if (addLabels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT OR IGNORE INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const labelName of addLabels) {
labelStmt.run(name, schema, labelName);
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
// Return updated variable
const updated = this.get(name, schema);
if (updated === null) {
throw new VariableNotFoundError(name, schema);
}
return 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);
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import type { Variable } from "./variable.js";
describe("Variable Type", () => {
test("Variable type uses (name, schema) composite key", () => {
const variable: Variable = {
name: "config",
schema: "ABC123DEF4567",
value: "XYZ789GHI0123",
created: 1234567890000,
updated: 1234567890000,
tags: { env: "prod" },
labels: ["critical"],
};
expect(variable.name).toBe("config");
expect(variable.schema).toBe("ABC123DEF4567");
// id and scope should not exist
expect((variable as unknown as { id?: unknown }).id).toBeUndefined();
expect((variable as unknown as { scope?: unknown }).scope).toBeUndefined();
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { Hash } from "./types.js";
/**
* Variable: mutable binding to an immutable CAS node
* Identified by composite key (name, schema)
*/
export type Variable = {
name: string; // variable name (unique per schema)
schema: Hash; // schema hash (part of composite key)
value: Hash; // CAS node hash
created: number; // epoch ms
updated: number; // epoch ms
tags: Record<string, string>; // key-value pairs
labels: string[]; // bare identifiers
};
@@ -15,7 +15,8 @@ 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 builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
@@ -25,7 +26,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
test("1.2: Meta-schema self-validates", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
@@ -34,7 +36,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
test("1.3: Meta-schema defines all supported keywords", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
@@ -57,7 +60,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
test("1.4: Meta-schema does not include unsupported keywords", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
@@ -69,13 +73,13 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
expect(properties).not.toHaveProperty("$id");
expect(properties).not.toHaveProperty("$defs");
expect(properties).not.toHaveProperty("allOf");
expect(properties).not.toHaveProperty("oneOf");
expect(properties).not.toHaveProperty("not");
});
test("1.5: Meta-schema node type equals its own hash", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
@@ -148,6 +152,18 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
expect(hash).toBeTruthy();
});
test("2.7b: Accept schema with oneOf", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
oneOf: [
{ properties: { status: { const: "ready" } }, required: ["status"] },
{ properties: { status: { const: "failed" } }, required: ["status"] },
],
});
expect(hash).toBeTruthy();
});
test("2.8: Accept schema with array items", async () => {
const store = new MemStore();
await bootstrap(store);
@@ -432,7 +448,8 @@ 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);
const builtinSchemas = await bootstrap(store);
const newMetaHash = builtinSchemas["@schema"] ?? "";
// The new hash should be different from the old system metadata hash
// We just verify it's a valid hash format
@@ -574,7 +591,8 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
describe("Test Suite 7: Meta-Schema Content Validation", () => {
test("7.1: Meta-schema allows recursive schema definitions", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"] ?? "";
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
+1 -4
View File
@@ -12,10 +12,7 @@
"skipLibCheck": true,
"paths": {
"@uncaged/json-cas": ["./packages/json-cas/src/index.ts"],
"@uncaged/json-cas-fs": ["./packages/json-cas-fs/src/index.ts"],
"@uncaged/json-cas-workflow": [
"./packages/json-cas-workflow/src/index.ts"
]
"@uncaged/json-cas-fs": ["./packages/json-cas-fs/src/index.ts"]
},
"composite": true,
"declaration": true,