Compare commits

...

49 Commits

Author SHA1 Message Date
xiaoju 038c901f4b feat: remove cat/schema commands, add list --type, enhance verify (Phase 1b)
Fixes #74
2026-05-31 14:41:38 +00:00
xiaoju f4cf92e128 Merge pull request 'feat: auto-bootstrap CAS store on open (Phase 1a)' (#76) from fix/73-auto-bootstrap into main
feat: auto-bootstrap CAS store on open (Phase 1a)

Fixes #73
2026-05-31 13:40:43 +00:00
xiaoju c8bf38cb81 feat: auto-bootstrap CAS store on open (Phase 1a)
Changes:
1. openStore() now async, auto-creates directory and bootstraps
2. Removed shouldCreate parameter and validateStoreExists()
3. All openStore() calls now awaited
4. Deleted init and bootstrap commands
5. Removed Issue #55 store validation tests (superseded by auto-bootstrap)
6. Enhanced error handling in openStore() for permission/path issues
7. Updated e2e snapshots after fixing missed await in cmdRender

Bootstrap is idempotent. Core tests using createMemoryStore() unaffected.

Fixes #73

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 13:34:43 +00:00
xingyue b93d7b229a Merge pull request 'feat(cli): convert e2e-check scenarios to snapshot fixture tests' (#68) from fix/66-e2e-snapshot-tests into main 2026-05-31 11:43:32 +00:00
xingyue 9912013b0a fix(cli): use dot notation for GC result assertions (Biome lint)
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-05-31 19:30:31 +08:00
xingyue 2ed097e207 fix(cli): make e2e snapshots stable across machines and runs
- Strip volatile fields (timestamp, created, updated) from JSON before
  snapshotting using a stripVolatile helper
- Remove toMatchSnapshot() from test 2.1 to avoid embedding machine-
  specific tmp paths in the snapshot; use toContain assertions instead
- Replace GC count snapshot with structural shape assertions so counts
  don't need to match exact phase-history state

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-05-31 19:29:25 +08:00
xingyue b0d5b05457 feat(cli): add e2e snapshot fixture tests for all CLI scenarios
Convert 46 e2e-check workflow scenarios to fast bun test snapshot tests.
7 describe phases share a single mkdtempSync store; hashes are deterministic
so cross-phase data dependencies work without re-creating data.

Closes #66

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-05-31 19:24:17 +08:00
xiaomo de20cfde53 Merge pull request 'refactor: e2e-check workflow 拆分为 4 角色' (#64) from fix/e2e-check-role-split into main 2026-05-31 10:58:50 +00:00
xingyue 9948db77ea refactor: split e2e-check workflow into 4 roles
- preparer: Docker setup, install, build, lint, unit test, init store
- tester: pure CLI scenario testing (receives container + store path)
- reporter: file Gitea issues (triggered by bugs or setup failures)
- cleanup: stop and remove Docker container

Also fixes template variable prefix (payload.name vs name).
2026-05-31 18:23:34 +08:00
xiaomo c60f05f650 Merge pull request 'fix: validate store directory exists before CLI operations' (#63) from fix/55-store-path-validation-rebase into main 2026-05-31 09:20:41 +00:00
xiaoju 314b076c05 fix: validate store path before read operations (Fixes #55)
- Add validateStoreExists() helper to check store directory existence
- Modify openStore() to accept shouldCreate parameter (default: false)
- Update read commands to validate store exists first
- Update write commands (init, put, schema put, hash) to allow creation
- Add 10 E2E tests covering all affected commands
- Improve error message: "Store not found at <path>" vs "Node not found"

Commands that now validate:
- get, has, verify, refs, walk, cat
- schema get, schema list

Commands that still create:
- init, put, schema put, hash
- var commands (use openVarStore)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 09:19:23 +00:00
xiaomo 664409b94a Merge pull request 'fix: workflow frontmatter schema 加 type: object' (#56) from fix/workflow-frontmatter-schema into main 2026-05-31 09:16:53 +00:00
xingyue f3f13e6f35 fix: add type: object to all oneOf variants in workflow frontmatter schemas
ajv strict mode requires explicit type: object when properties/required
are used. All three workflow YAMLs had this missing, causing frontmatter
validation to fail with:
  strict mode: missing type "object" for keyword "required"

Also added frontmatter output example in e2e-check.yaml procedure to
prevent agents from outputting bugs as plain strings instead of objects.
2026-05-31 17:15:43 +08:00
xiaomo 10c5c8f98e Merge pull request 'fix: clean error message for invalid schema in schema put command' (#61) from fix/54-schema-put-invalid-error into main 2026-05-31 09:15:37 +00:00
xiaomo d28003779e Merge pull request 'fix: detect missing root hash in render command' (#60) from fix/53-render-missing-hash-error into main 2026-05-31 09:15:35 +00:00
xiaomo 885a8e1147 Merge pull request 'test: add E2E template variable rendering tests' (#59) from fix/52-template-variable-rendering into main 2026-05-31 09:15:33 +00:00
xiaomo f0ffe6b234 Merge pull request 'fix: validate payload against schema in put command' (#57) from fix/50-schema-validation into main 2026-05-31 09:15:28 +00:00
xiaoju fc869cfc99 fix: resolve test failures for issue #53
Applied tester feedback to fix 5 test failures:

1. Updated error message format from "Node not found" to "CAS node not found"
   for consistency with existing tests in variable-store.test.ts and var.test.ts

2. Fixed CLI tests R9 and R10 to use bootstrap() directly instead of
   non-existent "types" command. Added imports for bootstrap and createFsStore.

3. Fixed render test 6.5 to pass actual schema Hash instead of entire
   bootstrap object (Record<string, Hash>)

4. Updated test expectations in render.test.ts (tests 1.5, 10.1, 10.2) to
   match new error message format

All 390 tests now pass. Core functionality verified:
- Missing root hash detection working correctly
- CLI exits with code 1 on missing hash
- Error message includes hash: "CAS node not found: <hash>"
- Nested nodes still render as cas: references (preserved behavior)
- Resolution decay behavior preserved

Fixes #53

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:36:19 +00:00
xiaoju 7fd2013ef2 fix: validate payload against schema in put command
Add schema validation to the `json-cas put` command to ensure data
integrity. The CLI now validates the payload against the specified
schema before storing, and exits with a non-zero code and descriptive
error message if validation fails.

Changes:
- Add schema existence check in cmdPut()
- Add payload validation before storing
- Exit with error code 1 on validation failure
- Provide helpful error messages indicating the file and schema
- Add comprehensive test suite with 16 test scenarios covering:
  - Valid data (regression tests)
  - Type mismatches (new validation)
  - Schema errors (edge cases)
  - Integration with existing features
  - Error message quality

The hash command continues to work without validation (dry-run
consistency), and schema put continues to use its own validation.

Fixes #50

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:24:13 +00:00
xiaoju 0b72c9400f style: apply biome formatting fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:21:10 +00:00
xiaoju eb36c16420 fix: detect missing root hash in render command and exit with error
When rendering a non-existent hash, the CLI now exits with code 1 and
displays an error message instead of silently outputting "cas:<hash>"
with exit code 0.

Changes:
- Updated CasNodeNotFoundError constructor signature to store hash
- Added root hash existence check in render() and renderAsync()
- Updated CLI error handling to catch CasNodeNotFoundError
- Added comprehensive test suite for missing hash error handling
- Updated existing incorrect tests (R2 and test 1.5)

The fix distinguishes between:
- Root hash (user-requested): Must exist or throw error
- Nested hash (during traversal): Renders as cas: reference (existing behavior)
- Resolution below epsilon: Renders as cas: reference (existing behavior)

Fixes #53

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:18:15 +00:00
xiaoju 3c8b16d7b1 fix: clean error message for invalid schema in schema put command
Fixes #54

The `json-cas schema put` command now catches SchemaValidationError
and displays a clean error message instead of showing a raw stack trace.

Changes:
- Import SchemaValidationError from @uncaged/json-cas
- Wrap putSchema() call in try-catch in cmdSchemaPut
- Catch SchemaValidationError specifically and call die(e.message)
- Add 5 comprehensive tests for invalid schema error handling

Test cases:
1. Invalid JSON Schema type value
2. Unknown schema keys
3. Invalid nested schema
4. Non-object root schema
5. Valid schema regression test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:14:23 +00:00
xiaoju 51e81c7b99 test: add E2E template variable rendering tests for issue #52
Add comprehensive test suite (Suite 9 and 10) covering template variable rendering:
- Suite 9: E2E Template Variable Rendering (12 tests)
  - Tests correct {{ payload.* }} syntax vs incorrect direct property access
  - Tests primitive payloads (string, number)
  - Tests nested objects, arrays, null values, booleans
  - Tests edge cases: empty strings, zero values, special characters
  - Validates CLI integration flow
- Suite 10: Context Variable Completeness (2 tests)
  - Verifies context propagation through recursive renders
  - Tests context isolation between parent and child nodes

All tests pass. Confirms the renderNode function correctly passes
node.payload to template context. Issue #52 was user error - templates
require {{ payload.name }} syntax, not {{ name }}.

Fixes #52

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:13:36 +00:00
xiaomo 2932aa5980 Merge pull request 'feat: ucas render --pipe/-p for stdin { type, value } input' (#49) from feat/48-render-pipe into main 2026-05-31 07:47:01 +00:00
xiaoju a0d7b67923 fix: address PR #49 review feedback
- Convention: renderDirect uses Store | null, options | null (no ?:)
- Validation: hash format check (13-char Crockford Base32) on stdin input
- DRY: remove collectRefsFromSchema, import collectRefs from schema.ts
- DRY: extract validateAndExtractOptions shared by render/renderAsync/renderDirect
- stdin: use process.stdin async iteration instead of /dev/stdin
- UX: error on --pipe + hash conflict instead of silent ignore
- Tests: add 9.10 (store present, schema missing)
2026-05-31 07:43:25 +00:00
xiaoju 7b29fe777c chore: remove stale temp files 2026-05-31 07:34:16 +00:00
xiaoju 64b8a88bdc feat: add renderDirect() and ucas render --pipe/-p
In-memory rendering of { type, value } envelopes without store writes.
Store is optional and read-only (for expanding nested cas_ref references).

CLI: ucas render --pipe/-p reads JSON from stdin.
Core: renderDirect(typeHash, value, store?, options?) for programmatic use.

Fixes #48
2026-05-31 07:34:07 +00:00
xiaoju 4717024e9b Merge pull request 'chore: Phase 4 cleanup — dedupe types, remove unused params, fix tests' (#47) from fix/46-phase4-cleanup into main 2026-05-31 07:19:42 +00:00
xiaoju 1e5f4b7c46 chore: Phase 4 cleanup — dedupe RenderOptions, remove unused param, fix test numbering
1. Deduplicate RenderOptions type
   - Remove duplicate definition from liquid-render.ts
   - Import from render.ts instead (canonical location)

2. Remove unused _globalDecay parameter
   - Remove from renderNode function signature
   - Update all call sites

3. Fix test numbering gaps
   - Suite 4: Renumber 4.4→4.2, 4.5→4.3
   - Suite 7→6: Renumber 7.1→6.1, 7.2→6.2, 7.4→6.3
   - Suite 8→7: Renumber all 8.x→7.x tests
   - Suite 10→8: Renumber 10.1→8.1
   - Result: consecutive suite numbering (1-8)

4. CLI test status: All 95 tests pass (no pre-existing failures found)

Fixes #46

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 07:11:44 +00:00
xiaoju 0a761f5289 Merge pull request 'feat: LiquidJS template rendering integration (#40)' (#45) from fix/40-liquidjs-integration into main 2026-05-31 06:08:31 +00:00
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
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
33 changed files with 10820 additions and 2697 deletions
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/
*.d.ts.map
*.tsbuildinfo
.worktrees/
+413
View File
@@ -0,0 +1,413 @@
name: "e2e-check"
description: "Docker-isolated E2E testing of json-cas CLI. Preparer builds from scratch, tester runs scenarios, reporter files bugs."
roles:
preparer:
description: "Spins up Docker container, copies repo, installs, builds, runs unit tests"
goal: "You set up a clean Docker environment for E2E testing. Your job is to start a container, install deps, build, lint, run unit tests, and initialize a CAS store. Report any setup failures as bugs."
capabilities:
- docker
procedure: |
1. Start a detached container:
```bash
docker run -d --name json-cas-e2e \
-v "<repoPath>:/src:ro" \
-w /workspace \
oven/bun:latest \
sleep 3600
```
2. Copy repo and install:
```bash
docker exec json-cas-e2e bash -c 'cp -r /src/. /workspace/ && cd /workspace && bun install'
```
✅ exit code 0, no missing peer deps
3. Build:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun run build'
```
✅ exit code 0, no type errors
4. Lint:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun run check'
```
✅ exit code 0
5. Unit tests:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun test'
```
✅ all pass (ignore dist/ false positives)
6. Init CAS store:
```bash
docker exec json-cas-e2e bash -c 'mkdir -p /tmp/cas-test && cd /workspace && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test init && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test bootstrap'
```
**Any failure here is a high-severity bug** — it means a clean environment can't build/run the project.
Set $status=ready if all steps pass. Set $status=setup_failed with failures list if anything breaks.
output: "Setup result summary."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "ready" }
containerName: { type: string }
storePath: { type: string }
repoPath: { type: string }
required: [$status, containerName, storePath, repoPath]
- type: object
properties:
$status: { const: "setup_failed" }
failures:
type: array
items:
type: object
properties:
title: { type: string }
command: { type: string }
expected: { type: string }
actual: { type: string }
severity: { type: string }
phase: { type: string }
required: [title, command, expected, actual, severity, phase]
repoPath: { type: string }
required: [$status, failures, repoPath]
tester:
description: "Runs CLI scenarios against the prepared Docker environment"
goal: "You are an exploratory QA agent. The Docker container is already running with the project built and a CAS store initialized. Run CLI test scenarios and report bugs."
capabilities:
- testing
- cli
procedure: |
The container `{{{containerName}}}` is already running with the project built.
Store path: `{{{storePath}}}`.
Run all commands via:
```bash
docker exec {{{containerName}}} bash -c 'cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}} <subcommand>'
```
Define a shorthand in your notes:
`CMD="cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}}"`
## Phase 1: CAS Core Operations
1. **bootstrap** — `$CMD bootstrap`
Expected: prints meta-schema hash (13-char Base32)
2. **schema put** — Create `/tmp/test-schema.json` in container, then `$CMD schema put /tmp/test-schema.json`
Schema: `{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"number"}},"required":["name"],"additionalProperties":false}`
Expected: prints type hash
3. **schema get** — `$CMD schema get <type-hash>`
Expected: returns the schema JSON
4. **schema list** — `$CMD schema list`
Expected: lists registered schemas
5. **put** — Create data file, `$CMD put <type-hash> /tmp/test-node.json`
Data: `{"name":"Alice","age":30}`
Expected: prints node hash
6. **get** — `$CMD get <node-hash>`
Expected: returns node JSON
7. **has (exists)** — `$CMD has <node-hash>`
Expected: true
8. **has (not exists)** — `$CMD has AAAAAAAAAAAAA`
Expected: false
9. **verify** — `$CMD verify <node-hash>`
Expected: ok
10. **refs** — `$CMD refs <node-hash>`
Expected: lists refs (may be empty)
11. **walk** — `$CMD walk <node-hash>`
Expected: shows traversal tree
12. **hash (dry run)** — `$CMD hash <type-hash> /tmp/test-node.json`
Expected: same hash as put
13. **cat** — `$CMD cat <node-hash>`
Expected: full node output
14. **cat --payload** — `$CMD cat <node-hash> --payload`
Expected: payload only (no type wrapper)
## Phase 2: Schema Validation
1. **Invalid node** — `$CMD put <type-hash> /tmp/bad-node.json` where bad-node = `{"name":123}`
Expected: validation error, non-zero exit
2. **schema validate** — `$CMD schema validate <node-hash>`
Expected: valid for good node
3. **Non-existent schema** — `$CMD put AAAAAAAAAAAAA /tmp/test-node.json`
Expected: error about missing schema
## Phase 3: Variable System
1. **var set** — `$CMD var set myapp/config <node-hash>`
Expected: creates variable
2. **var get** — `$CMD var get myapp/config --schema <type-hash>`
Expected: returns variable
3. **var list** — `$CMD var list`
Expected: shows all variables
4. **var list prefix** — `$CMD var list myapp/`
Expected: filtered results
5. **var set (update)** — Put a second node, `$CMD var set myapp/config <new-hash>`
Expected: upsert succeeds
6. **var tag** — `$CMD var tag myapp/config --schema <type-hash> env:prod important`
Expected: adds tag and label
7. **var list --tag** — `$CMD var list --tag env:prod`
Expected: finds tagged variable
8. **var list --tag (label)** — `$CMD var list --tag important`
Expected: finds labeled variable
9. **var tag remove** — `$CMD var tag myapp/config --schema <type-hash> :important`
Expected: removes label
10. **var delete** — `$CMD var delete myapp/config`
Expected: deletes variable
11. **var get (deleted)** — `$CMD var get myapp/config --schema <type-hash>`
Expected: not found error
## Phase 4: Template System
1. **template set** — Create template file, `$CMD template set <type-hash> /tmp/test.liquid`
Template: `Name: {{ payload.name }}, Age: {{ payload.age }}`
Expected: success
2. **template get** — `$CMD template get <type-hash>`
Expected: returns template text
3. **template list** — `$CMD template list`
Expected: lists templates
4. **template delete** — `$CMD template delete <type-hash>`
Expected: success
5. **template get (deleted)** — `$CMD template get <type-hash>`
Expected: not found error
## Phase 5: Render
1. Re-register template, then `$CMD render <node-hash>`
Expected: rendered output with payload values filled in
2. **render --resolution** — `$CMD render <node-hash> --resolution 0.5`
Expected: different resolution output
3. **render (bad hash)** — `$CMD render AAAAAAAAAAAAA`
Expected: graceful error, non-zero exit
## Phase 6: GC
1. **gc basic** — `$CMD gc`
Expected: runs without error
2. **gc preserves referenced** — Verify `$CMD has <node-hash>` still true
3. **gc collects orphans** — Put an orphan node (not in any variable), run gc, check it's gone
## Phase 7: Edge Cases & Error Handling
1. `$CMD get AAAAAAAAAAAAA` — non-existent
2. `$CMD put <type-hash> /nonexistent/file.json` — missing file
3. `$CMD var set "" <hash>` — empty name
4. `$CMD var set "bad name!" <hash>` — invalid name chars
5. `$CMD schema put /tmp/bad-schema.json` — `{"type":"invalid"}`
6. `$CMD` with no subcommand — should show help
7. `$CMD --store /nonexistent/path get <hash>` — bad store path
## Recording Results
For each scenario:
- ✅ Pass: works as expected
- ❌ Fail: unexpected behavior, crash, wrong output
- ⚠️ Questionable: works but confusing UX
Collect all ❌ and ⚠️. For each, record:
- Title (concise description)
- Command (exact command run)
- Expected behavior
- Actual behavior (include actual output)
- Severity: critical / high / medium / low
- Phase: which test phase
## CRITICAL: Frontmatter Output Format
Your response MUST start with YAML frontmatter. The `bugs` field MUST be an array of objects, NOT strings.
Example of CORRECT frontmatter:
```yaml
---
$status: bugs_found
containerName: json-cas-e2e
repoPath: /path/to/repo
bugs:
- title: "put does not validate data against schema"
command: "json-cas put <hash> bad-data.json"
expected: "Validation error, non-zero exit"
actual: "Accepted invalid data, exit 0"
severity: "high"
phase: "Schema Validation"
---
```
Do NOT write bugs as plain strings like `- some bug description`. Each bug MUST be an object with all 6 fields.
If all tests pass:
```yaml
---
$status: all_passed
containerName: json-cas-e2e
---
```
output: "Summary of all phases with pass/fail counts. Set $status."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "bugs_found" }
bugs:
type: array
items:
type: object
properties:
title: { type: string }
command: { type: string }
expected: { type: string }
actual: { type: string }
severity: { type: string }
phase: { type: string }
required: [title, command, expected, actual, severity, phase]
containerName: { type: string }
repoPath: { type: string }
required: [$status, bugs, containerName]
- type: object
properties:
$status: { const: "all_passed" }
containerName: { type: string }
required: [$status, containerName]
reporter:
description: "Opens Gitea issues for each bug found by the tester"
goal: "You are a bug reporter. You create well-formatted Gitea issues for each bug found during E2E testing."
capabilities:
- issue-management
procedure: |
1. Parse the bugs array from the tester's output
2. Group bugs by severity (critical first)
3. For each bug, create a Gitea issue:
```bash
tea issues create -r uncaged/json-cas \
-t "[E2E] <title>" \
-d "## Bug Report (E2E Check)
**Phase:** <phase>
**Severity:** <severity>
**Command:**
\`\`\`
<exact command>
\`\`\`
**Expected:** <expected>
**Actual:** <actual>
---
_Reported by e2e-check workflow (Docker isolated)_"
```
⚠️ If `tea issues create` fails with long body, use Gitea REST API:
```bash
eval "$(cfg env)" && GITEA_TOKEN=$(cfg get GITEA_TOKEN)
curl -s -X POST "https://git.shazhou.work/api/v1/repos/uncaged/json-cas/issues" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"[E2E] ...","body":"..."}'
```
4. Collect created issue numbers
output: "List created issues. Set $status."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "reported" }
issues:
type: array
items: { type: string }
required: [$status, issues]
- type: object
properties:
$status: { const: "partial" }
created: { type: number }
failed: { type: number }
required: [$status, created, failed]
cleanup:
description: "Stops and removes the Docker container"
goal: "You clean up the Docker environment after testing is complete."
capabilities:
- docker
procedure: |
Stop and remove the container:
```bash
docker stop {{{containerName}}} && docker rm {{{containerName}}}
```
Verify it's gone:
```bash
docker ps -a --filter name={{{containerName}}} --format '{{.Names}}'
```
Expected: empty output.
output: "Cleanup result."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "cleaned" }
required: [$status]
- type: object
properties:
$status: { const: "cleanup_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "preparer", prompt: "Set up Docker environment for E2E testing. Repo at {{{repoPath}}}." }
preparer:
ready: { role: "tester", prompt: "Environment ready. Container: {{{containerName}}}, store: {{{storePath}}}. Run all test scenarios." }
setup_failed: { role: "reporter", prompt: "Setup failures found. File these as bugs: {{{failures}}}" }
tester:
all_passed: { role: "cleanup", prompt: "All tests passed. Clean up container {{{containerName}}}." }
bugs_found: { role: "reporter", prompt: "File these bugs as Gitea issues: {{{bugs}}}" }
reporter:
reported: { role: "cleanup", prompt: "Bugs filed: {{{issues}}}. Clean up container {{{containerName}}}." }
partial: { role: "cleanup", prompt: "Filed {{{created}}} issues, {{{failed}}} failed. Clean up container {{{containerName}}}." }
cleanup:
cleaned: { role: "$END", prompt: "E2E check complete. Environment cleaned up." }
cleanup_failed: { role: "$END", prompt: "E2E check complete but cleanup failed: {{{error}}}" }
+22 -11
View File
@@ -51,16 +51,19 @@ roles:
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "clean" }
summary: { type: string }
required: [$status, summary]
- properties:
- type: object
properties:
$status: { const: "findings" }
report: { type: string }
targetWorkflow: { type: string }
required: [$status, report, targetWorkflow]
- properties:
- type: object
properties:
$status: { const: "wrong_project" }
workflowName: { type: string }
required: [$status, workflowName]
@@ -93,12 +96,14 @@ roles:
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
- type: object
properties:
$status: { const: "no_action" }
reason: { type: string }
required: [$status, reason]
@@ -128,12 +133,14 @@ roles:
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
@@ -157,12 +164,14 @@ roles:
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
@@ -191,11 +200,13 @@ roles:
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
- type: object
properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
+22 -11
View File
@@ -26,12 +26,14 @@ roles:
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
- type: object
properties:
$status: { const: "insufficient_info" }
required: [$status]
developer:
@@ -67,12 +69,14 @@ roles:
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
@@ -105,12 +109,14 @@ roles:
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
@@ -133,16 +139,19 @@ roles:
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "fix_code" }
report: { type: string }
required: [$status, report]
- properties:
- type: object
properties:
$status: { const: "fix_spec" }
report: { type: string }
required: [$status, report]
@@ -169,11 +178,13 @@ roles:
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
- type: object
properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
+5
View File
@@ -30,6 +30,7 @@
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
"liquidjs": "^10.27.0",
"xxhash-wasm": "^1.1.0",
},
},
@@ -141,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=="],
@@ -203,6 +206,8 @@
"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=="],
+2 -1
View File
@@ -3,7 +3,8 @@
"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",
@@ -0,0 +1,251 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"payload": {
"age": 30,
"name": "Alice",
},
"type": "7XX5H51CVD9H0",
}
`;
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `"ok"`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `""`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `"ERARPP19YJT05"`;
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "F68P1BZ46YDXM",
},
}
`;
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=7XX5H51CVD9H0"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
"{
"schemaHash": "7XX5H51CVD9H0",
"contentHash": "FC8WACA792B6F"
}"
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `"Name: {{ payload.name }}, Age: {{ payload.age }}"`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
"[
{
"schemaHash": "7XX5H51CVD9H0",
"preview": "Name: {{ payload.name }}, Age: {{ payload.age }}"
}
]"
`;
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
"{
"deleted": true
}"
`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 7XX5H51CVD9H0"`;
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: json-cas var set <name> <hash> [--tag <tag>...]"`;
exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`;
exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = `
"Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity + schema, print ok/corrupted/invalid
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
render --pipe/-p [options] Render { type, value } from stdin
list --type <hash-or-alias> List all hashes for a given type
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)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read { type, value } JSON from stdin for render"
`;
File diff suppressed because it is too large Load Diff
+520
View File
@@ -0,0 +1,520 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const entrypoint = resolve(import.meta.dir, "index.ts");
let tmpStore: string;
let varDbPath: string;
// Shared hashes across phases
let typeHash: string;
let nodeHash: string;
beforeAll(() => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
/**
* Parse JSON and strip volatile fields (timestamp, created, updated)
* so snapshots are stable across runs.
*/
function stripVolatile(json: string): unknown {
const strip = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(strip);
if (v !== null && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (k === "timestamp" || k === "created" || k === "updated") continue;
out[k] = strip(val);
}
return out;
}
return v;
};
return strip(JSON.parse(json));
}
// ---- Phase 1: CAS Core ----
describe("Phase 1: CAS Core", () => {
test("1.1 init + put with @object bootstraps store", async () => {
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
additionalProperties: false,
}),
);
// Use putSchema via the library to register schema, since CLI schema put is removed
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
const hash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
typeHash = hash;
expect(typeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("1.5 put returns node hash", async () => {
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout, exitCode } = await runCli(["put", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
nodeHash = stdout;
});
test("1.6 get returns node JSON (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["get", nodeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.7 has returns true for existing node", async () => {
const { stdout, exitCode } = await runCli(["has", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("true");
});
test("1.8 has returns false for non-existing hash", async () => {
const { stdout, exitCode } = await runCli(["has", "AAAAAAAAAAAAA"]);
expect(exitCode).toBe(0);
expect(stdout).toBe("false");
});
test("1.9 verify returns ok for valid node", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.10 refs lists direct references (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["refs", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.11 walk shows traversal tree (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["walk", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.12 hash dry-run returns same hash as put", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stdout, exitCode } = await runCli(["hash", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(stdout).toBe(nodeHash);
});
test("1.13 list --type returns nodes of that type", async () => {
const { stdout, exitCode } = await runCli(["list", "--type", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toContain(nodeHash);
});
});
// ---- Phase 2: Schema Validation ----
describe("Phase 2: Schema Validation", () => {
test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => {
const badFile = join(tmpStore, "bad-node.json");
writeFileSync(badFile, JSON.stringify({ name: 123 }));
const { stdout, stderr, exitCode } = await runCli([
"put",
typeHash,
badFile,
]);
expect(exitCode).not.toBe(0);
expect(stdout).toBe("");
expect(stderr).toContain("Validation failed");
expect(stderr).toContain(typeHash);
// Do NOT snapshot stderr — it embeds a machine-specific tmp path
});
test("2.2 verify on valid node returns ok (hash + schema)", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("ok");
});
test("2.3 put against non-existent schema hash fails", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stderr, exitCode } = await runCli([
"put",
"AAAAAAAAAAAAA",
nodeFile,
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 3: Variable System ----
describe("Phase 3: Variable System", () => {
test("3.1 var set creates variable", async () => {
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
nodeHash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.2 var get returns variable", async () => {
const { stdout, exitCode } = await runCli([
"var",
"get",
"myapp/config",
"--schema",
typeHash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain(nodeHash);
});
test("3.3 var list shows all variables", async () => {
const { stdout, exitCode } = await runCli(["var", "list"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain("myapp/config");
});
test("3.4 var list prefix filters by prefix", async () => {
const { stdout, exitCode } = await runCli(["var", "list", "myapp/"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain("myapp/config");
});
test("3.5 var set upsert updates existing variable", async () => {
const node2File = join(tmpStore, "node2.json");
writeFileSync(node2File, JSON.stringify({ name: "Bob", age: 25 }));
const { stdout: node2Hash } = await runCli(["put", typeHash, node2File]);
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
node2Hash.trim(),
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
// Restore original value
await runCli(["var", "set", "myapp/config", nodeHash]);
});
test("3.6 var tag adds kv tag and label", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"myapp/config",
"--schema",
typeHash,
"env:prod",
"important",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.7 var list --tag env:prod filters by kv tag", async () => {
const { stdout, exitCode } = await runCli([
"var",
"list",
"--tag",
"env:prod",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("myapp/config");
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.8 var list --tag important filters by label", async () => {
const { stdout, exitCode } = await runCli([
"var",
"list",
"--tag",
"important",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("myapp/config");
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.9 var tag remove deletes label", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"myapp/config",
"--schema",
typeHash,
":important",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
// Verify label is gone
const { stdout: listOut } = await runCli([
"var",
"list",
"--tag",
"important",
]);
expect(listOut).not.toContain("myapp/config");
});
test("3.10 var delete removes variable", async () => {
const { exitCode, stdout } = await runCli([
"var",
"delete",
"myapp/config",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.11 var get deleted variable returns not found", async () => {
const { stderr, exitCode } = await runCli([
"var",
"get",
"myapp/config",
"--schema",
typeHash,
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 4: Template System ----
describe("Phase 4: Template System", () => {
test("4.1 template set registers template", async () => {
const tmplFile = join(tmpStore, "test.liquid");
writeFileSync(tmplFile, "Name: {{ payload.name }}, Age: {{ payload.age }}");
const { exitCode, stdout } = await runCli([
"template",
"set",
typeHash,
tmplFile,
]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("4.2 template get returns template text", async () => {
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Name: {{ payload.name }}, Age: {{ payload.age }}");
expect(stdout).toMatchSnapshot();
});
test("4.3 template list shows registered templates", async () => {
const { stdout, exitCode } = await runCli(["template", "list"]);
expect(exitCode).toBe(0);
expect(stdout).toContain(typeHash);
expect(stdout).toMatchSnapshot();
});
test("4.4 template delete removes template", async () => {
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("4.5 template get deleted template returns not found", async () => {
const { stderr, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 5: Render ----
describe("Phase 5: Render", () => {
beforeAll(async () => {
const tmplFile = join(tmpStore, "render-template.liquid");
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
await runCli(["template", "set", typeHash, tmplFile]);
});
test("5.1 render fills payload variables", async () => {
const { stdout, exitCode } = await runCli(["render", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Hello Alice!");
expect(stdout).toMatchSnapshot();
});
test("5.2 render --resolution with different value", async () => {
const { stdout, exitCode } = await runCli([
"render",
nodeHash,
"--resolution",
"0.5",
]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("5.3 render non-existent hash fails with error", async () => {
const { stderr, exitCode } = await runCli(["render", "ZZZZZZZZZZZZZ"]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Node not found");
expect(stderr).toContain("ZZZZZZZZZZZZZ");
});
});
// ---- Phase 6: GC ----
describe("Phase 6: GC", () => {
let gcNodeHash: string;
beforeAll(async () => {
// Create a fresh node for GC tests (independent of shared nodeHash)
const gcNodeFile = join(tmpStore, "gc-node.json");
writeFileSync(gcNodeFile, JSON.stringify({ name: "GcAlice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, gcNodeFile]);
gcNodeHash = stdout.trim();
// Set a var referencing this node so it survives GC during Phase 6
await runCli(["var", "set", "gc-test/ref", gcNodeHash]);
});
test("6.1 gc runs without error", async () => {
const { exitCode, stdout } = await runCli(["gc"]);
expect(exitCode).toBe(0);
// Assert structural shape only — exact counts depend on phase history
const result = JSON.parse(stdout) as Record<string, unknown>;
expect(typeof result.total).toBe("number");
expect(typeof result.reachable).toBe("number");
expect(typeof result.collected).toBe("number");
expect(typeof result.scanned).toBe("number");
expect(result.total as number).toBeGreaterThanOrEqual(
result.reachable as number,
);
});
test("6.2 gc preserves node referenced by a var", async () => {
const { exitCode } = await runCli(["gc"]);
expect(exitCode).toBe(0);
const { stdout } = await runCli(["has", gcNodeHash]);
expect(stdout).toBe("true");
});
test("6.3 gc reclaims orphan node", async () => {
const orphanFile = join(tmpStore, "orphan.json");
writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 }));
const { stdout: orphanHashRaw } = await runCli([
"put",
typeHash,
orphanFile,
]);
const orphanHash = orphanHashRaw.trim();
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
expect(beforeGc).toBe("true");
await runCli(["gc"]);
const { stdout: afterGc } = await runCli(["has", orphanHash]);
expect(afterGc).toBe("false");
});
});
// ---- Phase 7: Edge Cases ----
describe("Phase 7: Edge Cases", () => {
test("7.1 get non-existent hash errors gracefully", async () => {
const { stderr, exitCode } = await runCli(["get", "AAAAAAAAAAAAA"]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
test("7.2 put with non-existent file errors with ENOENT", async () => {
const { stderr, exitCode } = await runCli([
"put",
typeHash,
"/nonexistent/file.json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("ENOENT");
});
test("7.3 var set empty name errors", async () => {
const { stderr, exitCode } = await runCli(["var", "set", "", nodeHash]);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
expect(stderr).toMatchSnapshot();
});
test("7.4 var set name with invalid chars errors", async () => {
const { stderr, exitCode } = await runCli([
"var",
"set",
"invalid name!",
nodeHash,
]);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
expect(stderr).toMatchSnapshot();
});
test("7.5 no subcommand shows help text", async () => {
const { stdout, stderr, exitCode: _exitCode } = await runCli([]);
const combined = stdout + stderr;
expect(combined.length).toBeGreaterThan(0);
expect(combined).toMatchSnapshot();
expect(combined.toLowerCase()).toContain("usage");
});
test("7.6 --store path is a file errors", async () => {
const fileAsStore = join(tmpStore, "not-a-directory");
writeFileSync(fileAsStore, "test");
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
fileAsStore,
"--var-db",
varDbPath,
"get",
"AAAAAAAAAAAAA",
],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stderr = (await new Response(proc.stderr).text()).trim();
expect(exitCode).not.toBe(0);
expect(stderr).toContain("not a directory");
});
});
+478 -214
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
import { mkdirSync, readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
@@ -11,18 +11,19 @@ import {
createVariableStore,
gc,
getSchema,
InvalidScopeError,
InvalidTagFormatError,
InvalidVariableNameError,
putSchema,
refs,
SchemaMismatchError,
renderAsync,
renderDirect,
TagLabelConflictError,
VariableNotFoundError,
validate,
verify,
walk,
} from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
// ---- Argument parsing ----
@@ -32,10 +33,14 @@ type Flags = Record<string, string | boolean | string[]>;
const VALUE_FLAGS = new Set([
"store",
"format",
"scope",
"value",
"var-db",
"tag",
"schema",
"resolution",
"decay",
"epsilon",
"inline",
"type",
]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
@@ -107,42 +112,62 @@ function readJsonFile(file: string): unknown {
}
}
function openStore(): Store {
return createFsStore(resolve(storePath));
/**
* Open the filesystem-backed CAS store.
* Automatically creates directory and bootstraps if needed.
*/
async function openStore(): Promise<Store> {
const fullPath = resolve(storePath);
return await openFsStore(fullPath);
}
function openVarStore(): VariableStore {
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
async function openVarStore(): Promise<VariableStore> {
const store = await openStore();
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 = await 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();
const store = await openStore();
// Define the Variable JSON Schema (simple version for envelope)
// Define the Variable JSON Schema (updated for new model with composite key)
const variableSchema: JSONSchema = {
title: "Variable",
type: "object",
properties: {
id: { type: "string" },
scope: { type: "string" },
value: { type: "string" },
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: [
"id",
"scope",
"value",
"name",
"schema",
"value",
"created",
"updated",
"tags",
@@ -202,71 +227,31 @@ function parseTagsLabels(args: string[]): {
// ---- 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);
}
async function cmdBootstrap(): Promise<void> {
const store = openStore();
const hash = await bootstrap(store);
console.log(hash);
}
async function cmdSchemaPut(args: string[]): Promise<void> {
const file = args[0];
if (!file) die("Usage: json-cas schema put <file.json>");
const schema = readJsonFile(file) as JSONSchema;
const store = openStore();
const hash = await putSchema(store, schema);
console.log(hash);
}
async function cmdSchemaGet(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas schema get <type-hash>");
const store = openStore();
const schema = getSchema(store, hash);
if (schema === null) die(`Schema not found: ${hash}`);
out(schema);
}
async function cmdSchemaList(): Promise<void> {
const store = openStore();
const metaHash = await bootstrap(store);
for (const hash of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null) {
const schema = node.payload as JSONSchema;
const name =
(schema.title as string | undefined) ??
(schema.description as string | undefined) ??
"(unnamed)";
console.log(`${hash} ${name}`);
}
}
}
async function cmdSchemaValidate(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas schema validate <hash>");
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const valid = validate(store, node);
console.log(valid ? "valid" : "invalid");
}
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 store = await openStore();
// Check if schema exists
const schema = getSchema(store, typeHash);
if (schema === null) {
console.error(`Schema not found: ${typeHash}`);
process.exit(1);
}
// Validate payload against schema before storing
const tempNode = { type: typeHash, payload, timestamp: Date.now() };
if (!validate(store, tempNode)) {
console.error(
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
);
process.exit(1);
}
const hash = await store.put(typeHash, payload);
console.log(hash);
}
@@ -274,7 +259,7 @@ async function cmdPut(args: string[]): Promise<void> {
async function cmdGet(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas get <hash>");
const store = openStore();
const store = await openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
out(node);
@@ -283,24 +268,29 @@ async function cmdGet(args: string[]): Promise<void> {
async function cmdHas(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas has <hash>");
const store = openStore();
const store = await openStore();
console.log(String(store.has(hash)));
}
async function cmdVerify(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas verify <hash>");
const store = openStore();
const store = await openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const ok = await verify(hash, node);
console.log(ok ? "ok" : "corrupted");
if (!ok) {
console.log("corrupted");
} else {
const valid = validate(store, node);
console.log(valid ? "ok" : "invalid");
}
}
async function cmdRefs(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas refs <hash>");
const store = openStore();
const store = await openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const refHashes = refs(store, node);
@@ -312,7 +302,7 @@ async function cmdRefs(args: string[]): Promise<void> {
async function cmdWalk(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas walk <hash> [--format tree]");
const store = openStore();
const store = await openStore();
const format = flags.format;
if (format === "tree") {
@@ -349,36 +339,136 @@ 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 cmdCat(args: string[]): Promise<void> {
async function cmdRender(args: string[]): Promise<void> {
const isPipe = flags.pipe === true || flags.p === true;
const hash = args[0];
if (!hash) die("Usage: json-cas cat <hash>");
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
if (flags.payload === true) {
out(node.payload);
} else {
out(node);
if (isPipe && hash) {
die("Cannot use --pipe/-p with a hash argument. Use one or the other.");
}
if (!isPipe && !hash) {
die(
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]\n ucas render --pipe/-p [--resolution <n>] [--decay <n>] [--epsilon <n>]",
);
}
const store = await 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 {
if (isPipe) {
// Read { type, value } JSON from stdin
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const input = Buffer.concat(chunks).toString("utf-8").trim();
if (!input) {
die("No input on stdin. Pipe a { type, value } JSON envelope.");
}
let envelope: { type: string; value: unknown };
try {
envelope = JSON.parse(input) as { type: string; value: unknown };
} catch {
die("Invalid JSON on stdin. Expected { type, value } envelope.");
return; // unreachable, for TS
}
if (
typeof envelope !== "object" ||
envelope === null ||
typeof envelope.type !== "string" ||
!("value" in envelope)
) {
die("Invalid envelope. Expected { type: string, value: unknown }.");
}
// Validate type hash format: 13-char uppercase Crockford Base32
if (!/^[0-9A-Z]{13}$/.test(envelope.type)) {
die(
`Invalid type hash: "${envelope.type}". Expected 13-character uppercase Crockford Base32 string.`,
);
}
const output = renderDirect(
envelope.type as Hash,
envelope.value,
store,
{
resolution,
decay,
epsilon,
},
);
process.stdout.write(output);
} else {
const varStore = await 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 CasNodeNotFoundError) {
die(`Error: Node not found: ${error.hash}`);
}
if (error instanceof Error) {
die(error.message);
}
die(String(error));
}
}
async function cmdVarCreate(_args: string[]): Promise<void> {
const scope = flags.scope as string | undefined;
const value = flags.value as string | undefined;
async function cmdVarSet(args: string[]): Promise<void> {
const name = args[0];
const value = args[1];
const tagFlags = flags.tag;
if (!scope) die("Usage: json-cas var create --scope <scope> --value <hash>");
if (!value) die("Usage: json-cas var create --scope <scope> --value <hash>");
if (!name || !value) {
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
}
const varStore = openVarStore();
const varStore = await openVarStore();
try {
// Parse tags/labels from --tag flags
@@ -391,18 +481,25 @@ async function cmdVarCreate(_args: string[]): Promise<void> {
// Check for conflicts in initial tags/labels
if (deleteNames.length > 0) {
die("Error: Cannot use deletion syntax (:name) in var create");
die("Error: Cannot use deletion syntax (:name) in var set");
}
const variable = varStore.create(scope, value, {
tags: Object.keys(tags).length > 0 ? tags : undefined,
labels: labels.length > 0 ? labels : undefined,
});
// 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 InvalidScopeError ||
e instanceof InvalidVariableNameError ||
e instanceof CasNodeNotFoundError ||
e instanceof TagLabelConflictError
) {
@@ -415,15 +512,19 @@ async function cmdVarCreate(_args: string[]): Promise<void> {
}
async function cmdVarGet(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var get <id>");
const name = args[0];
const schema = flags.schema as string | undefined;
const varStore = openVarStore();
if (!name || !schema) {
die("Usage: json-cas var get <name> --schema <hash>");
}
const varStore = await openVarStore();
try {
const variable = varStore.get(id);
const variable = varStore.get(name, schema);
if (variable === null) {
die(`Error: Variable not found: ${id}`);
die(`Error: Variable not found: name=${name}, schema=${schema}`);
}
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
@@ -432,44 +533,28 @@ async function cmdVarGet(args: string[]): Promise<void> {
}
}
async function cmdVarUpdate(args: string[]): Promise<void> {
const id = args[0];
const value = args[1];
if (!id || !value) {
die("Usage: json-cas var update <id> <hash>");
}
const varStore = openVarStore();
try {
const variable = varStore.update(id, value);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
e instanceof SchemaMismatchError ||
e instanceof CasNodeNotFoundError
) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarDelete(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var delete <id>");
const name = args[0];
const schema = flags.schema as string | undefined;
const varStore = openVarStore();
if (!name) {
die("Usage: json-cas var delete <name> [--schema <hash>]");
}
const varStore = await openVarStore();
try {
const variable = varStore.delete(id);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
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}`);
@@ -481,20 +566,24 @@ async function cmdVarDelete(args: string[]): Promise<void> {
}
async function cmdVarTag(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var tag <id> <tag>...");
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 <id> <tag>...");
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const varStore = openVarStore();
const varStore = await openVarStore();
try {
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
const variable = varStore.tag(id, {
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,
@@ -516,11 +605,12 @@ async function cmdVarTag(args: string[]): Promise<void> {
}
}
async function cmdVarList(_args: string[]): Promise<void> {
const scope = (flags.scope as string | undefined) ?? "";
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();
const varStore = await openVarStore();
try {
// Parse tags/labels from --tag flags
@@ -537,14 +627,15 @@ async function cmdVarList(_args: string[]): Promise<void> {
}
const variables = varStore.list({
scope,
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 InvalidScopeError) {
if (e instanceof InvalidVariableNameError) {
die(`Error: ${e.message}`);
}
throw e;
@@ -553,8 +644,172 @@ async function cmdVarList(_args: string[]): Promise<void> {
}
}
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 = await openStore();
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 = await openStore();
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 = await openStore();
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 = await openStore();
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 store = await openStore();
const varStore = createVariableStore(varDbPath, store);
try {
@@ -565,38 +820,54 @@ async function cmdGc(_args: string[]): Promise<void> {
}
}
async function cmdList(_args: string[]): Promise<void> {
const typeFlag = flags.type;
if (typeof typeFlag !== "string")
die("Usage: json-cas list --type <hash-or-alias>");
const typeHash = await resolveTypeHash(typeFlag);
const store = await openStore();
for (const hash of store.listByType(typeHash)) {
console.log(hash);
}
}
function printUsage(): void {
console.log(`\
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
verify <hash> Verify integrity + schema, print ok/corrupted/invalid
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
var create --scope <s> --value <h> [--tag <tag>...] Create a variable
var get <id> Get a variable by ID
var update <id> <hash> Update variable value
var delete <id> Delete a variable
var tag <id> <tag>... Add/update/delete tags and labels
var list [--scope <prefix>] [--tag <tag>...] List variables (filter by scope/tags/labels)
render <hash> [options] Render node as YAML with resolution decay
render --pipe/-p [options] Render { type, value } from stdin
list --type <hash-or-alias> List all hashes for a given type
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)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)`);
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read { type, value } JSON from stdin for render`);
}
// ---- Dispatch ----
@@ -609,35 +880,6 @@ if (!cmd) {
}
switch (cmd) {
case "init":
await cmdInit();
break;
case "bootstrap":
await cmdBootstrap();
break;
case "schema": {
const [sub, ...subRest] = rest;
switch (sub) {
case "put":
await cmdSchemaPut(subRest);
break;
case "get":
await cmdSchemaGet(subRest);
break;
case "list":
await cmdSchemaList();
break;
case "validate":
await cmdSchemaValidate(subRest);
break;
default:
die(`Unknown schema subcommand: ${sub ?? "(none)"}`);
}
break;
}
case "put":
await cmdPut(rest);
break;
@@ -666,22 +908,23 @@ switch (cmd) {
await cmdHash(rest);
break;
case "cat":
await cmdCat(rest);
case "render":
await cmdRender(rest);
break;
case "list":
await cmdList(rest);
break;
case "var": {
const [sub, ...subRest] = rest;
switch (sub) {
case "create":
await cmdVarCreate(subRest);
case "set":
await cmdVarSet(subRest);
break;
case "get":
await cmdVarGet(subRest);
break;
case "update":
await cmdVarUpdate(subRest);
break;
case "delete":
await cmdVarDelete(subRest);
break;
@@ -697,6 +940,27 @@ switch (cmd) {
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;
+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
+1 -1
View File
@@ -1 +1 @@
export { createFsStore } from "./store.js";
export { createFsStore, openStore } from "./store.js";
+130 -9
View File
@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasNode } from "@uncaged/json-cas";
@@ -10,7 +16,7 @@ import {
verify,
} from "@uncaged/json-cas";
import { createFsStore } from "./store.js";
import { createFsStore, openStore } from "./store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "json-cas-fs-test-"));
@@ -43,7 +49,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 +64,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 +111,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 +259,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 +293,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;
@@ -308,3 +318,114 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// openStore – async with auto-bootstrap
// ──────────────────────────────────────────────────────────────────────────────
describe("openStore – async with auto-bootstrap", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("openStore returns Promise<Store>", async () => {
const store = await openStore(dir);
expect(store).toBeDefined();
expect(typeof store.put).toBe("function");
expect(typeof store.get).toBe("function");
});
test("openStore auto-creates directory when it doesn't exist", async () => {
const nested = join(dir, "sub", "nested", "store");
expect(existsSync(nested)).toBe(false);
const store = await openStore(nested);
expect(existsSync(nested)).toBe(true);
// Verify store works
const typeHash = await computeSelfHash({ name: "t" });
const hash = await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
});
test("openStore works when directory already exists", async () => {
// Pre-create the directory
const store1 = await openStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
await store1.put(typeHash, { x: 1 });
// Open again
const store2 = await openStore(dir);
expect(store2.listByType(typeHash)).toHaveLength(1);
});
test("openStore throws error when path exists but is not a directory", async () => {
const filePath = join(dir, "not-a-dir");
writeFileSync(filePath, "test");
await expect(openStore(filePath)).rejects.toThrow();
});
test("openStore auto-bootstraps on first open (empty directory)", async () => {
const store = await openStore(dir);
// Check that bootstrap schemas exist
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
expect(metaHash).toBeDefined();
expect(store.has(metaHash as string)).toBe(true);
// Verify all core schemas exist
expect(store.has(builtinSchemas["@string"] as string)).toBe(true);
expect(store.has(builtinSchemas["@number"] as string)).toBe(true);
expect(store.has(builtinSchemas["@object"] as string)).toBe(true);
expect(store.has(builtinSchemas["@array"] as string)).toBe(true);
expect(store.has(builtinSchemas["@bool"] as string)).toBe(true);
expect(store.has(builtinSchemas["@schema"] as string)).toBe(true);
});
test("openStore bootstrap is idempotent on subsequent opens", async () => {
const store1 = await openStore(dir);
const schemas1 = await bootstrap(store1);
const count1 = store1.listAll().length;
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
const count2 = store2.listAll().length;
// Same schemas, same count
expect(schemas1).toEqual(schemas2);
expect(count1).toBe(count2);
});
test("openStore works on already-bootstrapped store", async () => {
// Bootstrap manually first
const store1 = createFsStore(dir);
const schemas1 = await bootstrap(store1);
// Open with openStore
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
expect(schemas1).toEqual(schemas2);
});
test("openStore auto-bootstraps old store without bootstrap", async () => {
// Create a store with some data but no bootstrap
const store1 = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "custom" });
await store1.put(typeHash, { data: "old" });
// Open with openStore - should auto-bootstrap
const store2 = await openStore(dir);
const schemas = await bootstrap(store2);
expect(store2.has(schemas["@schema"] as string)).toBe(true);
// Old data still exists
expect(store2.listByType(typeHash)).toHaveLength(1);
});
});
+56
View File
@@ -5,6 +5,7 @@ import {
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
@@ -13,6 +14,7 @@ import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
bootstrap,
cborEncode,
computeHash,
computeSelfHash,
@@ -219,3 +221,57 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return store;
}
/**
* Open a filesystem-backed CAS store with automatic directory creation and bootstrap.
* This is an async function that:
* 1. Creates the directory (with recursive: true) if it doesn't exist
* 2. Validates that the path is actually a directory (not a file)
* 3. Creates the store
* 4. Runs bootstrap (which is idempotent)
*
* @param dir - The directory path for the store
* @returns A Promise resolving to the BootstrapCapableStore
* @throws Error if the path exists but is not a directory
*/
export async function openStore(dir: string): Promise<BootstrapCapableStore> {
// Create directory if it doesn't exist
try {
mkdirSync(dir, { recursive: true });
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "EACCES") {
throw new Error(`Permission denied: cannot access store at ${dir}`);
}
if (nodeError.code === "ENOTDIR") {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
}
throw error;
}
// Validate that the path is a directory
try {
const stats = statSync(dir);
if (!stats.isDirectory()) {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
throw new Error(`Store not found at ${dir}`);
}
}
throw error;
}
// Create the store
const store = createFsStore(dir);
// Bootstrap (idempotent)
await bootstrap(store);
return store;
}
+1
View File
@@ -21,6 +21,7 @@
"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);
}
});
});
+24 -5
View File
@@ -64,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,
};
}
+108 -380
View File
@@ -1,451 +1,179 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
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 { createVariableStore, type VariableStore } from "./variable-store.js";
import { VariableStore } from "./variable-store.js";
function tmpDbPath(): string {
return `/tmp/test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`;
}
const tmpDbPath = () =>
join(
tmpdir(),
`test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
);
describe("gc()", () => {
describe("GC - Variable Model Refactoring", () => {
let store: Store;
let varStore: VariableStore;
let dbPath: string;
beforeEach(() => {
store = createMemoryStore();
dbPath = tmpDbPath();
varStore = createVariableStore(dbPath, store);
});
afterEach(() => {
varStore.close();
try {
unlinkSync(dbPath);
} catch {
// ignore
// Ignore cleanup errors
}
});
test("preserves variable-referenced nodes", async () => {
// Bootstrap and create schema
const _metaHash = await bootstrap(store);
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);
// Put two nodes
const hashRef = await store.put(schemaHash, { name: "referenced" });
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
// Create variable pointing to hashRef
varStore.create("test/", hashRef);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("config", hashRef);
// Run GC
const stats = gc(store, varStore);
// Verify: hashRef exists, hashOrphan removed
expect(store.has(hashRef)).toBe(true);
expect(store.get(hashRef)).not.toBe(null);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(1);
expect(stats.collected).toBeGreaterThanOrEqual(1);
varStore.close();
});
test("removes orphaned nodes", async () => {
// Bootstrap and create schema
const _metaHash = await bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
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);
// Put two nodes
const hashRef = await store.put(schemaHash, { name: "referenced" });
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
const hashA = await store.put(schemaAHash, { x: 42 });
const hashB = await store.put(schemaBHash, { y: "hello" });
const hashOrphan = await store.put(schemaAHash, { x: 99 });
// Create variable pointing to hashRef
varStore.create("test/", hashRef);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// Run GC
gc(store, varStore);
varStore.set("config", hashA);
varStore.set("config", hashB);
// Verify: orphan removed
expect(store.has(hashOrphan)).toBe(false);
});
test("removes nodes after variable deletion", async () => {
// Bootstrap and create schema
const _metaHash = await bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
// Put node
const hashRef = await store.put(schemaHash, { name: "referenced" });
// Create variable
const variable = varStore.create("test/", hashRef);
// Delete variable
varStore.delete(variable.id);
// Run GC
gc(store, varStore);
// Verify: node removed
expect(store.has(hashRef)).toBe(false);
});
test("preserves schema nodes of reachable nodes", async () => {
// Bootstrap and create schema
const _metaHash = await bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
// Put node
const hashData = await store.put(schemaHash, { name: "data" });
// Create variable
varStore.create("test/", hashData);
// Run GC
gc(store, varStore);
// Verify: schema preserved
expect(store.has(schemaHash)).toBe(true);
expect(store.get(schemaHash)).not.toBe(null);
});
test("collects unused schemas", async () => {
// Bootstrap
const _metaHash = await bootstrap(store);
// Create two schemas
const schemaUsed = {
type: "object",
properties: { name: { type: "string" } },
};
const schemaOrphan = {
type: "object",
properties: { age: { type: "number" } },
};
const schemaUsedHash = await putSchema(store, schemaUsed);
const schemaOrphanHash = await putSchema(store, schemaOrphan);
// Put node using schemaUsed
const hashData = await store.put(schemaUsedHash, { name: "data" });
// Create variable
varStore.create("test/", hashData);
// Run GC
gc(store, varStore);
// Verify: schemaUsed preserved, schemaOrphan collected
expect(store.has(schemaUsedHash)).toBe(true);
expect(store.has(schemaOrphanHash)).toBe(false);
});
test("preserves bootstrap meta-schema", async () => {
// Bootstrap
const metaHash = await bootstrap(store);
// Create other schemas and nodes (not referencing meta directly)
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
const hashData = await store.put(schemaHash, { name: "data" });
// Create variable
varStore.create("test/", hashData);
// Run GC
gc(store, varStore);
// Verify: meta-schema preserved
expect(store.has(metaHash)).toBe(true);
});
test("handles multiple variables with shared references", async () => {
// Bootstrap and create schema
const _metaHash = await bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
// Put shared node
const hashShared = await store.put(schemaHash, { name: "shared" });
// Create two variables
varStore.create("test/", hashShared);
varStore.create("test/", hashShared);
// Run GC
const stats = gc(store, varStore);
// Verify: node preserved, scanned: 2
expect(store.has(hashShared)).toBe(true);
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("deleting one variable doesn't remove shared node", async () => {
// Bootstrap and create schema
const _metaHash = await bootstrap(store);
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);
// Put shared node
const hashShared = await store.put(schemaHash, { name: "shared" });
const hashRef = await store.put(schemaHash, { name: "referenced" });
// Create two variables
const var1 = varStore.create("test/", hashShared);
const _var2 = varStore.create("test/", hashShared);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// Delete one variable
varStore.delete(var1.id);
varStore.set("config", hashRef);
varStore.remove("config", schemaHash);
// Run GC
gc(store, varStore);
// Verify: node still preserved
expect(store.has(hashShared)).toBe(true);
});
test("deleting all variables removes shared node", async () => {
// Bootstrap and create schema
const _metaHash = await bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
// Put shared node
const hashShared = await store.put(schemaHash, { name: "shared" });
// Create two variables
const var1 = varStore.create("test/", hashShared);
const var2 = varStore.create("test/", hashShared);
// Delete both variables
varStore.delete(var1.id);
varStore.delete(var2.id);
// Run GC
gc(store, varStore);
// Verify: node removed
expect(store.has(hashShared)).toBe(false);
});
test("walks deep reference chains", async () => {
// Bootstrap
const _metaHash = await bootstrap(store);
// Create schema with cas_ref field and a name field to differentiate nodes
const schemaTree = {
type: "object",
properties: {
name: { type: "string" },
child: {
anyOf: [{ type: "null" }, { type: "string", format: "cas_ref" }],
},
},
};
const schemaTreeHash = await putSchema(store, schemaTree);
// Create chain: A -> B -> C
const hashC = await store.put(schemaTreeHash, { name: "C", child: null });
const hashB = await store.put(schemaTreeHash, {
name: "B",
child: hashC,
});
const hashA = await store.put(schemaTreeHash, {
name: "A",
child: hashB,
});
// Create orphan (different content so it gets a different hash)
const hashOrphan = await store.put(schemaTreeHash, {
name: "orphan",
child: null,
});
// Create variable pointing to A
varStore.create("test/", hashA);
// Run GC
const stats = gc(store, varStore);
// Verify: A, B, C preserved; orphan removed
expect(store.has(hashA)).toBe(true);
expect(store.has(hashB)).toBe(true);
expect(store.has(hashC)).toBe(true);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.reachable).toBeGreaterThanOrEqual(4); // A, B, C, schemaTree
});
test("handles cycles without hanging", async () => {
// Bootstrap
const _metaHash = await bootstrap(store);
// Create schema with cas_ref field
const schema = {
type: "object",
properties: {
child: { type: "string", format: "cas_ref" },
},
};
const schemaHash = await putSchema(store, schema);
// We need to create a cycle: X -> Y -> X
// This requires getting the hash before putting
// For simplicity, we'll create a self-referencing node
const hashX = await store.put(schemaHash, { child: "placeholder" });
// Now manually update the node to reference itself (this is a workaround)
// In reality, we can't easily create cycles without modifying the store
// But the walk function should handle it gracefully
// Create variable
varStore.create("test/", hashX);
// Run GC - should not hang
const stats = gc(store, varStore);
// Verify: completes without hanging
expect(store.has(hashX)).toBe(true);
expect(stats.scanned).toBe(1);
});
test("handles empty variable store", async () => {
// Bootstrap
const metaHash = await bootstrap(store);
// Create some schemas and nodes
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
const hash1 = await store.put(schemaHash, { name: "node1" });
const hash2 = await store.put(schemaHash, { name: "node2" });
// NO variables created
// Run GC
const stats = gc(store, varStore);
// Verify: all user nodes removed, scanned: 0
expect(store.has(hashRef)).toBe(false);
expect(stats.scanned).toBe(0);
expect(stats.collected).toBeGreaterThan(0);
expect(store.has(hash1)).toBe(false);
expect(store.has(hash2)).toBe(false);
// Bootstrap meta-schema should still exist
expect(store.has(metaHash)).toBe(true);
varStore.close();
});
test("handles empty CAS store", () => {
// Fresh store, no bootstrap, no nodes
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);
// Run GC
const stats = gc(store, varStore);
// Verify: completes without error
expect(stats.total).toBe(0);
expect(stats.reachable).toBe(0);
expect(stats.collected).toBe(0);
expect(stats.scanned).toBe(0);
});
test("is global across all scopes", async () => {
// Bootstrap
const _metaHash = await bootstrap(store);
// Create schema
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
// Create variables in different scopes
const hashA = await store.put(schemaHash, { name: "A" });
const hashB = await store.put(schemaHash, { name: "B" });
const hashC = await store.put(schemaHash, { name: "C" });
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
varStore.create("uwf/thread/", hashA);
varStore.create("uwf/workflow/", hashB);
varStore.create("app/config/", hashC);
// Run GC
const stats = gc(store, varStore);
// Verify: all three preserved, orphan removed
expect(store.has(hashA)).toBe(true);
expect(store.has(hashB)).toBe(true);
expect(store.has(hashC)).toBe(true);
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("returns accurate stats", async () => {
// Bootstrap
const _metaHash = await bootstrap(store);
test("GC integration with refactored variable store", async () => {
store = createMemoryStore();
await bootstrap(store);
// Create schemas and nodes
const schema1 = {
type: "object",
properties: { name: { type: "string" } },
};
const schema2 = {
type: "object",
properties: { age: { type: "number" } },
};
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 schema1Hash = await putSchema(store, schema1);
const schema2Hash = await putSchema(store, schema2);
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" });
// Create 2 nodes
const hash1 = await store.put(schema1Hash, { name: "node1" });
const hash2 = await store.put(schema2Hash, { age: 42 });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// Create 3 orphans
const _orphan1 = await store.put(schema1Hash, { name: "orphan1" });
const _orphan2 = await store.put(schema1Hash, { name: "orphan2" });
const _orphan3 = await store.put(schema2Hash, { age: 99 });
// Create variables
varStore.set("var1", hashA1);
varStore.set("var2", hashA2);
varStore.set("var3", hashB);
// Create 2 variables
varStore.create("test/", hash1);
varStore.create("test/", hash2);
// 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);
// Count total before GC
const totalBefore = 8; // metaHash, schema1Hash, schema2Hash, hash1, hash2, orphan1, orphan2, orphan3
// Delete one variable
varStore.remove("var2", schemaAHash);
// Run GC
const stats = gc(store, varStore);
// Verify stats
expect(stats.total).toBe(totalBefore);
// 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);
expect(stats.reachable).toBe(5); // metaHash, schema1Hash, schema2Hash, hash1, hash2
expect(stats.collected).toBe(3); // orphan1, orphan2, orphan3
});
test("handles missing CAS nodes gracefully", async () => {
// Bootstrap
const _metaHash = await bootstrap(store);
// Create schema
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
// Create a valid node
const hashValid = await store.put(schemaHash, { name: "valid" });
// Create variable pointing to valid node
varStore.create("test/", hashValid);
// Manually create a variable with non-existent hash (simulate corruption)
// We'll use the variable store's internal DB to insert a fake variable
// For simplicity, we'll skip this test as it requires internal access
// Run GC
const stats = gc(store, varStore);
// Verify: completes without crashing
expect(stats.scanned).toBeGreaterThanOrEqual(1);
varStore.close();
});
});
+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);
});
});
+9 -2
View File
@@ -4,6 +4,13 @@ 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,
renderDirect,
} from "./render.js";
export type { JSONSchema } from "./schema.js";
export {
getSchema,
@@ -15,12 +22,12 @@ export {
} from "./schema.js";
export { createMemoryStore } from "./store.js";
export type { CasNode, Hash, Store } from "./types.js";
export type { Variable, VariableId } from "./variable.js";
export type { Variable } from "./variable.js";
export {
CasNodeNotFoundError,
createVariableStore,
InvalidScopeError,
InvalidTagFormatError,
InvalidVariableNameError,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
File diff suppressed because it is too large Load Diff
+285
View File
@@ -0,0 +1,285 @@
import { type Context, Liquid, type TagToken } from "liquidjs";
import type { RenderOptions } from "./render.js";
import { putSchema } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
const DEFAULT_RESOLUTION = 1.0;
const DEFAULT_DECAY = 0.5;
const DEFAULT_EPSILON = 0.01;
const FLOAT_TOLERANCE = 1e-10;
/**
* Render a CAS node using LiquidJS templates with resolution-based decay.
* Templates are discovered via variables: @ucas/template/text/<type-hash>
*/
export async function renderWithTemplate(
store: Store,
varStore: VariableStore,
hash: Hash,
options?: RenderOptions,
): Promise<string> {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
// Validate parameters
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
if (decay <= 0 || decay > 1) {
throw new Error("decay must be in (0, 1]");
}
if (epsilon < 0) {
throw new Error("epsilon must be >= 0");
}
const visited = new Set<Hash>();
// Create Liquid engine
const engine = createLiquidEngine(store, varStore, decay);
return await renderNode(
engine,
store,
varStore,
hash,
resolution,
epsilon,
visited,
);
}
/**
* Create a Liquid engine instance with custom render tag
*/
function createLiquidEngine(
store: Store,
varStore: VariableStore,
globalDecay: number,
): Liquid {
const engine = new Liquid({
strictFilters: false,
strictVariables: false,
});
// Type for storing parsed tag data
type RenderTagState = {
variable: string;
decay: number | undefined;
};
// Register custom {% render %} tag
// Capture store, varStore, globalDecay in closure
engine.registerTag("render", {
parse(token: TagToken) {
// Parse "variable" or "variable, decay: 0.7" syntax
const args = token.args.trim();
const match = args.match(/^(\S+)(?:,\s*decay:\s*([\d.]+))?$/);
if (!match) {
throw new Error(
`Invalid render tag syntax: ${args}. Expected: {% render variable %} or {% render variable, decay: 0.7 %}`,
);
}
// Store parsed values on the tag instance
const state = this as unknown as RenderTagState;
state.variable = match[1] as string;
state.decay = match[2] ? Number.parseFloat(match[2]) : undefined;
// Validate decay if provided
if (state.decay !== undefined) {
if (state.decay <= 0 || state.decay > 1) {
throw new Error("decay must be in (0, 1]");
}
}
},
async render(ctxLiquid: Context) {
// Access parsed values
const state = this as unknown as RenderTagState;
const variable = state.variable;
const explicitDecay = state.decay;
// Resolve the variable to a hash (split on dots for nested paths)
const variablePath = variable.split(".");
const value = ctxLiquid.get(variablePath);
// Handle null/undefined - render as empty
if (value === null || value === undefined) {
return "";
}
// Handle non-string values - render as empty
if (typeof value !== "string") {
return "";
}
const nodeHash = value as Hash;
// Get current render context
const currentResolution = ctxLiquid.get(["resolution"]) as number;
const currentEpsilon = ctxLiquid.get(["epsilon"]) as number;
// Compute child resolution using decay priority:
// 1. Template explicit decay (explicitDecay)
// 2. Global decay (from CLI/options)
// 3. Engine default (0.5)
const effectiveDecay =
explicitDecay !== undefined
? explicitDecay
: (globalDecay ?? DEFAULT_DECAY);
const childResolution = currentResolution * effectiveDecay;
// Recursively render the referenced node
const visited = ctxLiquid.get(["__visited"]) as Set<Hash>;
const output = await renderNode(
engine,
store,
varStore,
nodeHash,
childResolution,
currentEpsilon,
visited,
);
return output;
},
});
return engine;
}
/**
* Render a single node with template or fallback to cas: reference
*/
async function renderNode(
engine: Liquid,
store: Store,
varStore: VariableStore,
hash: Hash,
currentResolution: number,
epsilon: number,
visited: Set<Hash>,
): Promise<string> {
// Check if resolution is below threshold
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
return `cas:${hash}`;
}
// Fetch the node
const node = store.get(hash);
if (node === null) {
return `cas:${hash}`;
}
// Cycle detection
if (visited.has(hash)) {
return `cas:${hash}`;
}
visited.add(hash);
try {
// Try to find a template for this node's type
const template = await findTemplate(store, varStore, node.type);
if (template === null) {
// No template found - this is handled by the caller (fallback to YAML)
// For now, return a simple representation
visited.delete(hash);
return renderFallback(store, node.payload);
}
// Render using the template
const context = {
resolution: currentResolution,
epsilon,
hash,
payload: node.payload,
type: node.type,
timestamp: node.timestamp,
__visited: visited, // Pass visited set through context
};
const output = await engine.parseAndRender(template, context);
visited.delete(hash);
return output;
} catch (error) {
visited.delete(hash);
throw error;
}
}
/**
* Find a template for a given type hash
*/
async function findTemplate(
store: Store,
varStore: VariableStore,
typeHash: Hash,
): Promise<string | null> {
const varName = `@ucas/template/text/${typeHash}`;
try {
// Find the string schema hash (we need this to query variables)
const stringSchema = await putSchema(store, { type: "string" });
const variable = varStore.get(varName, stringSchema);
if (variable === null) {
return null;
}
const templateNode = store.get(variable.value);
if (templateNode === null) {
return null;
}
// Template should be a string
if (typeof templateNode.payload !== "string") {
return null;
}
return templateNode.payload;
} catch {
return null;
}
}
/**
* Fallback renderer for nodes without templates
*/
function renderFallback(_store: Store, payload: unknown): string {
// Simple YAML-like representation
if (payload === null) {
return "null\n";
}
if (typeof payload === "string") {
return `${payload}\n`;
}
if (typeof payload === "number" || typeof payload === "boolean") {
return `${payload}\n`;
}
if (Array.isArray(payload)) {
if (payload.length === 0) {
return "[]\n";
}
return `- ${payload.join("\n- ")}\n`;
}
if (typeof payload === "object") {
const obj = payload as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) {
return "{}\n";
}
const pairs = keys.map((key) => `${key}: ${obj[key]}`);
return `${pairs.join("\n")}\n`;
}
return "null\n";
}
File diff suppressed because it is too large Load Diff
+343
View File
@@ -0,0 +1,343 @@
import { renderWithTemplate } from "./liquid-render.js";
import { collectRefs, getSchema, putSchema, refs } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
import { CasNodeNotFoundError } from "./variable-store.js";
export type RenderOptions = {
resolution?: number; // (0, 1], default 1.0
decay?: number; // (0, 1], default 0.5
epsilon?: number; // >= 0, default 0.01
varStore?: VariableStore; // Optional: for template lookup
};
const DEFAULT_RESOLUTION = 1.0;
const DEFAULT_DECAY = 0.5;
const DEFAULT_EPSILON = 0.01;
// Small tolerance for floating point comparison
const FLOAT_TOLERANCE = 1e-10;
/**
* Extract and validate resolution/decay/epsilon from options.
*/
function validateAndExtractOptions(
options:
| Pick<RenderOptions, "resolution" | "decay" | "epsilon">
| null
| undefined,
): { resolution: number; decay: number; epsilon: number } {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
if (decay <= 0 || decay > 1) {
throw new Error("decay must be in (0, 1]");
}
if (epsilon < 0) {
throw new Error("epsilon must be >= 0");
}
return { resolution, decay, epsilon };
}
/**
* Render a CAS node as YAML with resolution-based decay.
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
* This is the synchronous version without template support.
* For template support, use renderAsync() with varStore.
*/
export function render(
store: Store,
hash: Hash,
options?: RenderOptions,
): string {
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
// Check if root node exists
if (store.get(hash) === null) {
throw new CasNodeNotFoundError(hash);
}
const visited = new Set<Hash>();
return renderNode(store, hash, resolution, decay, epsilon, visited);
}
/**
* Async render with LiquidJS template support.
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
* If varStore is provided, attempts to use LiquidJS templates first, fallback to YAML.
*/
export async function renderAsync(
store: Store,
hash: Hash,
options?: RenderOptions,
): Promise<string> {
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
// Check if root node exists
if (store.get(hash) === null) {
throw new CasNodeNotFoundError(hash);
}
const varStore = options?.varStore;
// If varStore provided, try template rendering first
if (varStore !== undefined) {
try {
const node = store.get(hash);
if (node !== null) {
// Check if a template exists for this type
const templateExists = await hasTemplate(store, varStore, node.type);
if (templateExists) {
return await renderWithTemplate(store, varStore, hash, {
resolution,
decay,
epsilon,
});
}
}
} catch {
// Fall through to YAML rendering
}
}
// Fallback to YAML rendering
const visited = new Set<Hash>();
return renderNode(store, hash, resolution, decay, epsilon, visited);
}
/**
* Render a value directly (in-memory) without requiring it to be stored.
* Accepts a raw { type, value } pair. Store is optional and read-only —
* used only for schema lookup and expanding nested cas_ref references.
* No data is written to the store.
*/
export function renderDirect(
typeHash: Hash,
value: unknown,
store: Store | null,
options: Omit<RenderOptions, "varStore"> | null,
): string {
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
// Try to get schema from store to identify cas_ref fields
let refSet = new Set<Hash>();
if (store !== null) {
const schema = getSchema(store, typeHash);
if (schema !== null) {
refSet = new Set(collectRefs(schema, value));
}
}
const childResolution = resolution * decay;
const visited = new Set<Hash>();
return renderValue(
store ?? null,
value,
refSet,
childResolution,
decay,
epsilon,
visited,
);
}
/**
* Check if a template exists for a given type
*/
async function hasTemplate(
store: Store,
varStore: VariableStore,
typeHash: Hash,
): Promise<boolean> {
const varName = `@ucas/template/text/${typeHash}`;
try {
const stringSchema = await putSchema(store, { type: "string" });
const variable = varStore.get(varName, stringSchema);
return variable !== null;
} catch {
return false;
}
}
function renderNode(
store: Store | null,
hash: Hash,
currentResolution: number,
decay: number,
epsilon: number,
visited: Set<Hash>,
): string {
// Check if resolution is below threshold (with floating point tolerance)
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
return `cas:${hash}`;
}
// Fetch the node
const node = store !== null ? store.get(hash) : null;
if (node === null) {
// Missing node - render as cas: reference
return `cas:${hash}`;
}
// Cycle detection
if (visited.has(hash)) {
return `cas:${hash}`;
}
visited.add(hash);
// Get references from this node's schema
const nodeRefs = store !== null ? refs(store, node) : [];
const refSet = new Set(nodeRefs);
// Calculate child resolution for next level
const childResolution = currentResolution * decay;
// Render the payload with recursive expansion of cas_ref fields
const rendered = renderValue(
store,
node.payload,
refSet,
childResolution,
decay,
epsilon,
visited,
);
visited.delete(hash);
return rendered;
}
function renderValue(
store: Store | null,
value: unknown,
refHashes: Set<Hash>,
childResolution: number,
decay: number,
epsilon: number,
visited: Set<Hash>,
): string {
// Handle null
if (value === null) {
return "null\n";
}
// Handle primitives
if (typeof value === "string") {
// Check if this string is a cas_ref
if (refHashes.has(value as Hash)) {
// Recursively render the referenced node
return renderNode(
store,
value as Hash,
childResolution,
decay,
epsilon,
visited,
);
}
// Otherwise, render as YAML string
return toYamlString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return `${value}\n`;
}
// Handle arrays
if (Array.isArray(value)) {
if (value.length === 0) {
return "[]\n";
}
const items = value.map((item) => {
const itemYaml = renderValue(
store,
item,
refHashes,
childResolution,
decay,
epsilon,
visited,
);
return indent(itemYaml.trim(), 2);
});
return `- ${items.join("\n- ")}\n`;
}
// Handle objects
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) {
return "{}\n";
}
const pairs = keys.map((key) => {
const val = obj[key];
const valYaml = renderValue(
store,
val,
refHashes,
childResolution,
decay,
epsilon,
visited,
);
const trimmedVal = valYaml.trim();
// If value is multiline, indent it
if (trimmedVal.includes("\n")) {
return `${key}:\n${indent(trimmedVal, 2)}`;
}
return `${key}: ${trimmedVal}`;
});
return `${pairs.join("\n")}\n`;
}
return "null\n";
}
function toYamlString(str: string): string {
// Handle special characters
if (
str.includes("\n") ||
str.includes(":") ||
str.includes("#") ||
str.includes("[") ||
str.includes("]") ||
str.includes("{") ||
str.includes("}") ||
str.includes("'") ||
str.includes('"') ||
str.startsWith(" ") ||
str.endsWith(" ")
) {
// Use double-quoted string with escaping
const escaped = str
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n");
return `"${escaped}"\n`;
}
return `${str}\n`;
}
function indent(text: string, spaces: number): string {
const prefix = " ".repeat(spaces);
return text
.split("\n")
.map((line) => (line ? prefix + line : line))
.join("\n");
}
+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" });
+6 -2
View File
@@ -142,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",
@@ -182,7 +186,7 @@ export function validate(store: Store, node: CasNode): boolean {
* Handles: direct format, anyOf (nullable refs), items (array refs),
* properties (nested objects), and additionalProperties (record refs).
*/
function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
const result: Hash[] = [];
if (schema.format === "cas_ref") {
File diff suppressed because it is too large Load Diff
+353 -151
View File
@@ -1,18 +1,30 @@
import { Database } from "bun:sqlite";
import { ulid } from "ulidx";
import type { Store } from "./types.js";
import type { Variable, VariableId } from "./variable.js";
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(id: VariableId) {
super(`Variable not found: ${id}`);
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,
@@ -23,16 +35,12 @@ export class SchemaMismatchError extends Error {
}
}
export class InvalidScopeError extends Error {
constructor(scope: string) {
super(`Invalid scope: scope must end with / (got: ${scope})`);
this.name = "InvalidScopeError";
}
}
export class CasNodeNotFoundError extends Error {
constructor(hash: string) {
super(`CAS node not found: ${hash}`);
constructor(
public readonly hash: string,
message?: string,
) {
super(message ?? `CAS node not found: ${hash}`);
this.name = "CasNodeNotFoundError";
}
}
@@ -66,37 +74,41 @@ export class VariableStore {
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 (
id TEXT PRIMARY KEY,
scope TEXT NOT NULL,
value TEXT NOT NULL,
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL
updated INTEGER NOT NULL,
PRIMARY KEY (name, schema)
);
CREATE INDEX IF NOT EXISTS idx_var_scope ON variables(scope);
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_id TEXT NOT NULL,
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (variable_id, key),
FOREIGN KEY (variable_id) REFERENCES variables(id) ON DELETE CASCADE
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_id TEXT NOT NULL,
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (variable_id, name),
FOREIGN KEY (variable_id) REFERENCES variables(id) ON DELETE CASCADE
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);
@@ -106,11 +118,51 @@ export class VariableStore {
}
/**
* Validate that scope ends with /
* Validate variable name format
* @ is allowed at the start of the first segment (system-reserved)
*/
private validateScope(scope: string): void {
if (!scope.endsWith("/")) {
throw new InvalidScopeError(scope);
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)`,
);
}
}
}
@@ -126,19 +178,146 @@ export class VariableStore {
}
/**
* Create a new variable
* Load tags for a variable
*/
create(
scope: string,
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 {
this.validateScope(scope);
// 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 ?? [];
@@ -150,38 +329,37 @@ export class VariableStore {
}
}
const id = ulid();
const now = Date.now();
this.db.exec("BEGIN TRANSACTION");
try {
const stmt = this.db.prepare(`
INSERT INTO variables (id, scope, value, schema, created, updated)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO variables (name, schema, value, created, updated)
VALUES (?, ?, ?, ?, ?)
`);
stmt.run(id, scope, value, schema, now, now);
stmt.run(name, schema, value, now, now);
// Insert tags
if (tagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT INTO variable_tags (variable_id, key, value)
VALUES (?, ?, ?)
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, val] of Object.entries(tags)) {
tagStmt.run(id, key, val);
tagStmt.run(name, schema, key, val);
}
}
// Insert labels
if (labels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT INTO variable_labels (variable_id, name)
VALUES (?, ?)
INSERT INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const name of labels) {
labelStmt.run(id, name);
for (const labelName of labels) {
labelStmt.run(name, schema, labelName);
}
}
@@ -192,10 +370,9 @@ export class VariableStore {
}
return {
id,
scope,
value,
name,
schema,
value,
created: now,
updated: now,
tags,
@@ -204,54 +381,27 @@ export class VariableStore {
}
/**
* Load tags for a variable
* Get a variable by name, optionally with schema
*/
private loadTags(id: VariableId): Record<string, string> {
const stmt = this.db.prepare(`
SELECT key, value
FROM variable_tags
WHERE variable_id = ?
`);
const rows = stmt.all(id) 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
* Get a variable by name and schema
* @param name - Variable name
* @param schema - Schema hash (required)
* @returns Variable if found, null otherwise
*/
private loadLabels(id: VariableId): string[] {
get(name: string, schema: Hash): Variable | null {
// Precise match with schema
const stmt = this.db.prepare(`
SELECT name
FROM variable_labels
WHERE variable_id = ?
ORDER BY name ASC
`);
const rows = stmt.all(id) as Array<{ name: string }>;
return rows.map((row) => row.name);
}
/**
* Get a variable by ID
*/
get(id: VariableId): Variable | null {
const stmt = this.db.prepare(`
SELECT id, scope, value, schema, created, updated
SELECT name, schema, value, created, updated
FROM variables
WHERE id = ?
WHERE name = ? AND schema = ?
`);
const row = stmt.get(id) as
const row = stmt.get(name, schema) as
| {
id: string;
scope: string;
value: string;
name: string;
schema: string;
value: string;
created: number;
updated: number;
}
@@ -262,14 +412,13 @@ export class VariableStore {
return null;
}
const tags = this.loadTags(row.id);
const labels = this.loadLabels(row.id);
const tags = this.loadTags(row.name, row.schema);
const labels = this.loadLabels(row.name, row.schema);
return {
id: row.id,
scope: row.scope,
value: row.value,
name: row.name,
schema: row.schema,
value: row.value,
created: row.created,
updated: row.updated,
tags,
@@ -280,10 +429,13 @@ export class VariableStore {
/**
* Update a variable's value (with schema validation)
*/
update(id: VariableId, value: string): Variable {
const existing = this.get(id);
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(id);
throw new VariableNotFoundError(name, schema);
}
const newSchema = this.extractSchema(value);
@@ -296,10 +448,10 @@ export class VariableStore {
const stmt = this.db.prepare(`
UPDATE variables
SET value = ?, updated = ?
WHERE id = ?
WHERE name = ? AND schema = ?
`);
stmt.run(value, now, id);
stmt.run(value, now, name, schema);
return {
...existing,
@@ -309,43 +461,69 @@ export class VariableStore {
}
/**
* Delete a variable
* Remove a variable (or all variants if schema omitted)
*/
delete(id: VariableId): Variable {
const existing = this.get(id);
if (existing === null) {
throw new VariableNotFoundError(id);
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 id = ?
DELETE FROM variables WHERE name = ?
`);
stmt.run(id);
stmt.run(name);
return existing;
return variants;
}
/**
* List variables matching a scope prefix
* List variables with optional filters
*/
list(options?: {
scope?: string;
namePrefix?: string;
exactName?: string;
schema?: Hash;
tags?: Record<string, string>;
labels?: string[];
}): Variable[] {
const scope = options?.scope ?? "";
// 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 ?? [];
// Validate scope format (must end with / if non-empty)
if (scope !== "" && !scope.endsWith("/")) {
throw new InvalidScopeError(scope);
}
// Build query with tag/label filtering
// Build query with filters
let query = `
SELECT DISTINCT v.id, v.scope, v.value, v.schema, v.created, v.updated
SELECT DISTINCT v.name, v.schema, v.value, v.created, v.updated
FROM variables v
`;
@@ -357,7 +535,8 @@ export class VariableStore {
const key = tagKeys[i] as string;
const value = filterTags[key] as string;
query += `
INNER JOIN variable_tags t${i} ON v.id = t${i}.variable_id
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);
@@ -367,36 +546,52 @@ export class VariableStore {
for (let i = 0; i < filterLabels.length; i++) {
const label = filterLabels[i] as string;
query += `
INNER JOIN variable_labels l${i} ON v.id = l${i}.variable_id
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);
}
// Scope filter (always present)
query += " WHERE v.scope LIKE ? || '%'";
params.push(scope);
// 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<{
id: string;
scope: string;
value: string;
name: string;
schema: string;
value: string;
created: number;
updated: number;
}>;
return rows.map((row) => ({
id: row.id,
scope: row.scope,
value: row.value,
name: row.name,
schema: row.schema,
value: row.value,
created: row.created,
updated: row.updated,
tags: this.loadTags(row.id),
labels: this.loadLabels(row.id),
tags: this.loadTags(row.name, row.schema),
labels: this.loadLabels(row.name, row.schema),
}));
}
@@ -404,16 +599,20 @@ export class VariableStore {
* Add/update/delete tags and labels
*/
tag(
id: VariableId,
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 {
const existing = this.get(id);
// Validate name format
this.validateName(name);
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(id);
throw new VariableNotFoundError(name, schema);
}
const addTags = operations.add ?? {};
@@ -433,14 +632,17 @@ export class VariableStore {
}
}
for (const name of addLabels) {
for (const labelName of addLabels) {
// Check if this name is being added as a tag in the same operation
if (newTagKeys.includes(name)) {
throw new TagLabelConflictError(name, "tag", "label");
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[name] !== undefined && !deleteNames.includes(name)) {
throw new TagLabelConflictError(name, "tag", "label");
if (
existing.tags[labelName] !== undefined &&
!deleteNames.includes(labelName)
) {
throw new TagLabelConflictError(labelName, "tag", "label");
}
}
@@ -451,43 +653,43 @@ export class VariableStore {
try {
// Update timestamp
const updateStmt = this.db.prepare(`
UPDATE variables SET updated = ? WHERE id = ?
UPDATE variables SET updated = ? WHERE name = ? AND schema = ?
`);
updateStmt.run(now, id);
updateStmt.run(now, name, schema);
// Delete tags and labels
if (deleteNames.length > 0) {
const deleteTagStmt = this.db.prepare(`
DELETE FROM variable_tags WHERE variable_id = ? AND key = ?
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ? AND key = ?
`);
const deleteLabelStmt = this.db.prepare(`
DELETE FROM variable_labels WHERE variable_id = ? AND name = ?
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ? AND name = ?
`);
for (const name of deleteNames) {
deleteTagStmt.run(id, name);
deleteLabelStmt.run(id, 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_id, key, value)
VALUES (?, ?, ?)
INSERT OR REPLACE INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, value] of Object.entries(addTags)) {
tagStmt.run(id, key, value);
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_id, name)
VALUES (?, ?)
INSERT OR IGNORE INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const name of addLabels) {
labelStmt.run(id, name);
for (const labelName of addLabels) {
labelStmt.run(name, schema, labelName);
}
}
@@ -498,9 +700,9 @@ export class VariableStore {
}
// Return updated variable
const updated = this.get(id);
const updated = this.get(name, schema);
if (updated === null) {
throw new VariableNotFoundError(id);
throw new VariableNotFoundError(name, schema);
}
return updated;
}
@@ -1,740 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import {
TagLabelConflictError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
describe("VariableStore - Tags and Labels (RFC-20 Phase 2)", () => {
let store: Store;
let varStore: VariableStore;
let dbPath: string;
let schemaHash: string;
let hashA: string;
let hashB: string;
let hashC: string;
beforeEach(async () => {
dbPath = join(tmpdir(), `test-variables-phase2-${Date.now()}.db`);
store = createMemoryStore();
// Create test schema
schemaHash = await store.put("BOOTSTRAPHASH", {
type: "object",
properties: { name: { type: "string" } },
});
// Create test CAS nodes
hashA = await store.put(schemaHash, { name: "a" });
hashB = await store.put(schemaHash, { name: "b" });
hashC = await store.put(schemaHash, { name: "c" });
varStore = new VariableStore(dbPath, store);
});
afterEach(() => {
varStore.close();
try {
unlinkSync(dbPath);
} catch {
// Ignore cleanup errors
}
});
describe("Test Group 0: Setup and Backward Compatibility", () => {
test("0.1: Create variable without tags/labels", () => {
const variable = varStore.create("uwf/thread/", hashA);
expect(variable.tags).toEqual({});
expect(variable.labels).toEqual([]);
expect(variable.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
});
test("0.2: Get variable returns empty tags and labels", () => {
const created = varStore.create("uwf/thread/", hashA);
const retrieved = varStore.get(created.id);
expect(retrieved).not.toBeNull();
expect(retrieved?.tags).toEqual({});
expect(retrieved?.labels).toEqual([]);
});
test("0.3: Create variable with initial tags", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active", workflow: "solve-issue" },
});
expect(variable.tags).toEqual({
status: "active",
workflow: "solve-issue",
});
expect(variable.labels).toEqual([]);
});
test("0.4: Create variable with initial labels", () => {
const variable = varStore.create("uwf/workflow/", hashC, {
labels: ["pinned"],
});
expect(variable.tags).toEqual({});
expect(variable.labels).toEqual(["pinned"]);
});
test("0.5: Create variable with both tags and labels", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active" },
labels: ["pinned"],
});
expect(variable.tags).toEqual({ status: "active" });
expect(variable.labels).toEqual(["pinned"]);
});
test("0.6: Create variable with conflicting tag/label throws error", () => {
expect(() =>
varStore.create("uwf/thread/", hashA, {
tags: { workflow: "solve-issue" },
labels: ["workflow"],
}),
).toThrow(TagLabelConflictError);
});
});
describe("Test Group 1: Tag Operations", () => {
test("1.1: Add tag to existing variable", async () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active" },
});
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = varStore.tag(variable.id, {
add: { priority: "high" },
});
expect(updated.tags).toEqual({
status: "active",
priority: "high",
});
expect(updated.updated).toBeGreaterThan(variable.updated);
});
test("1.2: Tag same-key override", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active" },
});
const updated = varStore.tag(variable.id, {
add: { status: "completed" },
});
expect(updated.tags).toEqual({ status: "completed" });
expect(Object.keys(updated.tags)).toHaveLength(1);
});
test("1.3: Delete tag using delete array", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active", workflow: "solve-issue" },
});
const updated = varStore.tag(variable.id, {
delete: ["status"],
});
expect(updated.tags).toEqual({ workflow: "solve-issue" });
expect(updated.tags.status).toBeUndefined();
});
test("1.4: Delete non-existent tag is idempotent", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active" },
});
const updated = varStore.tag(variable.id, {
delete: ["nonexistent"],
});
expect(updated.tags).toEqual({ status: "active" });
});
test("1.5: Multiple tag operations in single call", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active", workflow: "solve-issue" },
});
const updated = varStore.tag(variable.id, {
add: { env: "production", region: "us-west" },
delete: ["workflow"],
});
expect(updated.tags).toEqual({
status: "active",
env: "production",
region: "us-west",
});
});
test("1.6: Delete then add same key in single operation", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active" },
});
const updated = varStore.tag(variable.id, {
delete: ["status"],
add: { status: "new" },
});
expect(updated.tags).toEqual({ status: "new" });
});
});
describe("Test Group 2: Label Operations", () => {
test("2.1: Add label to existing variable", () => {
const variable = varStore.create("uwf/thread/", hashA);
const updated = varStore.tag(variable.id, {
addLabels: ["archived"],
});
expect(updated.labels).toContain("archived");
expect(updated.labels).toHaveLength(1);
});
test("2.2: Delete label using delete array", () => {
const variable = varStore.create("uwf/thread/", hashA, {
labels: ["archived", "pinned"],
});
const updated = varStore.tag(variable.id, {
delete: ["archived"],
});
expect(updated.labels).toEqual(["pinned"]);
});
test("2.3: Add duplicate label is idempotent", () => {
const variable = varStore.create("uwf/workflow/", hashC, {
labels: ["pinned"],
});
const updated = varStore.tag(variable.id, {
addLabels: ["pinned"],
});
expect(updated.labels).toEqual(["pinned"]);
});
test("2.4: Multiple label operations in single call", () => {
const variable = varStore.create("uwf/thread/", hashA, {
labels: ["archived"],
});
const updated = varStore.tag(variable.id, {
addLabels: ["experimental", "deprecated"],
delete: ["archived"],
});
expect(updated.labels).toHaveLength(2);
expect(updated.labels).toContain("experimental");
expect(updated.labels).toContain("deprecated");
expect(updated.labels).not.toContain("archived");
});
});
describe("Test Group 3: Tag/Label Mutual Exclusion", () => {
test("3.1: Label conflicts with existing tag key", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { workflow: "solve-issue" },
});
expect(() =>
varStore.tag(variable.id, {
addLabels: ["workflow"],
}),
).toThrow(TagLabelConflictError);
// Verify variable state unchanged
const retrieved = varStore.get(variable.id);
expect(retrieved?.tags).toEqual({ workflow: "solve-issue" });
expect(retrieved?.labels).toEqual([]);
});
test("3.2: Tag conflicts with existing label", () => {
const variable = varStore.create("uwf/workflow/", hashC, {
labels: ["pinned"],
});
expect(() =>
varStore.tag(variable.id, {
add: { pinned: "true" },
}),
).toThrow(TagLabelConflictError);
// Verify variable state unchanged
const retrieved = varStore.get(variable.id);
expect(retrieved?.tags).toEqual({});
expect(retrieved?.labels).toEqual(["pinned"]);
});
test("3.3: Delete then add resolves conflict", () => {
const variable = varStore.create("uwf/workflow/", hashC, {
labels: ["pinned"],
});
const updated = varStore.tag(variable.id, {
delete: ["pinned"],
add: { pinned: "true" },
});
expect(updated.tags).toEqual({ pinned: "true" });
expect(updated.labels).toEqual([]);
});
test("3.4: Simultaneous conflicting operations in same call", () => {
const variable = varStore.create("uwf/thread/", hashA);
expect(() =>
varStore.tag(variable.id, {
add: { newkey: "value" },
addLabels: ["newkey"],
}),
).toThrow(TagLabelConflictError);
});
});
describe("Test Group 4: Query - Scope Filtering", () => {
test("4.1: List with exact scope match", () => {
const var1 = varStore.create("uwf/thread/", hashA, {
tags: { status: "active" },
});
const var2 = varStore.create("uwf/thread/", hashB, {
tags: { status: "completed" },
});
varStore.create("uwf/workflow/", hashC);
const results = varStore.list({ scope: "uwf/thread/" });
expect(results).toHaveLength(2);
expect(results.map((v) => v.id)).toContain(var1.id);
expect(results.map((v) => v.id)).toContain(var2.id);
});
test("4.2: List with scope prefix match", () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("uwf/thread/", hashB);
const var3 = varStore.create("uwf/workflow/", hashC);
const results = varStore.list({ scope: "uwf/" });
expect(results).toHaveLength(3);
expect(results.map((v) => v.id)).toContain(var1.id);
expect(results.map((v) => v.id)).toContain(var2.id);
expect(results.map((v) => v.id)).toContain(var3.id);
});
test("4.3: List all variables (no scope filter)", () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("app/config/", hashB);
const results = varStore.list();
expect(results).toHaveLength(2);
expect(results.map((v) => v.id)).toContain(var1.id);
expect(results.map((v) => v.id)).toContain(var2.id);
});
test("4.4: List with non-matching scope returns empty", () => {
varStore.create("uwf/thread/", hashA);
const results = varStore.list({ scope: "app/config/" });
expect(results).toEqual([]);
});
});
describe("Test Group 5: Query - Tag Filtering", () => {
test("5.1: Filter by tag key-value pair", () => {
const var1 = varStore.create("uwf/thread/", hashA, {
tags: { status: "completed" },
});
const var2 = varStore.create("uwf/thread/", hashB, {
tags: { status: "completed" },
});
varStore.create("uwf/thread/", hashC, {
tags: { status: "active" },
});
const results = varStore.list({
tags: { status: "completed" },
});
expect(results).toHaveLength(2);
expect(results.map((v) => v.id)).toContain(var1.id);
expect(results.map((v) => v.id)).toContain(var2.id);
});
test("5.2: Filter by non-existent tag returns empty", () => {
varStore.create("uwf/thread/", hashA, {
tags: { status: "active" },
});
const results = varStore.list({
tags: { nonexistent: "value" },
});
expect(results).toEqual([]);
});
test("5.3: Multiple tag filters use AND logic", () => {
const var1 = varStore.create("uwf/thread/", hashA, {
tags: { status: "completed", priority: "high" },
});
varStore.create("uwf/thread/", hashB, {
tags: { status: "completed", priority: "low" },
});
varStore.create("uwf/thread/", hashC, {
tags: { status: "active", priority: "high" },
});
const results = varStore.list({
tags: { status: "completed", priority: "high" },
});
expect(results).toHaveLength(1);
expect(results[0]?.id).toBe(var1.id);
});
});
describe("Test Group 6: Query - Label Filtering", () => {
test("6.1: Filter by label", () => {
const var1 = varStore.create("uwf/workflow/", hashA, {
labels: ["pinned"],
});
varStore.create("uwf/workflow/", hashB);
const results = varStore.list({
labels: ["pinned"],
});
expect(results).toHaveLength(1);
expect(results[0]?.id).toBe(var1.id);
});
test("6.2: Filter by non-existent label returns empty", () => {
varStore.create("uwf/workflow/", hashA, {
labels: ["pinned"],
});
const results = varStore.list({
labels: ["nonexistent"],
});
expect(results).toEqual([]);
});
test("6.3: Multiple label filters use AND logic", () => {
const var1 = varStore.create("uwf/thread/", hashA, {
labels: ["experimental", "deprecated"],
});
varStore.create("uwf/thread/", hashB, {
labels: ["experimental"],
});
const results = varStore.list({
labels: ["experimental", "deprecated"],
});
expect(results).toHaveLength(1);
expect(results[0]?.id).toBe(var1.id);
});
});
describe("Test Group 7: Query - Combined Filtering", () => {
test("7.1: Scope + tag filter", () => {
const var1 = varStore.create("uwf/thread/", hashA, {
tags: { status: "completed" },
});
const var2 = varStore.create("uwf/thread/", hashB, {
tags: { status: "completed" },
});
varStore.create("uwf/workflow/", hashC, {
tags: { status: "completed" },
});
const results = varStore.list({
scope: "uwf/thread/",
tags: { status: "completed" },
});
expect(results).toHaveLength(2);
expect(results.map((v) => v.id)).toContain(var1.id);
expect(results.map((v) => v.id)).toContain(var2.id);
});
test("7.2: Scope + label filter", () => {
const var1 = varStore.create("uwf/workflow/", hashA, {
labels: ["pinned"],
});
varStore.create("uwf/thread/", hashB, {
labels: ["pinned"],
});
const results = varStore.list({
scope: "uwf/workflow/",
labels: ["pinned"],
});
expect(results).toHaveLength(1);
expect(results[0]?.id).toBe(var1.id);
});
test("7.3: Scope + multiple filters", () => {
const var1 = varStore.create("uwf/thread/", hashA, {
tags: { status: "completed", priority: "high" },
});
varStore.create("uwf/thread/", hashB, {
tags: { status: "completed" },
});
varStore.create("uwf/workflow/", hashC, {
tags: { status: "completed", priority: "high" },
});
const results = varStore.list({
scope: "uwf/",
tags: { status: "completed", priority: "high" },
});
expect(results).toHaveLength(2);
expect(results.map((v) => v.id)).toContain(var1.id);
});
test("7.4: Combined filters with no matches", () => {
varStore.create("uwf/thread/", hashA, {
tags: { status: "active" },
});
const results = varStore.list({
scope: "app/",
tags: { status: "completed" },
});
expect(results).toEqual([]);
});
});
describe("Test Group 8: Edge Cases and Error Handling", () => {
test("8.1: Tag operation on non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
expect(() =>
varStore.tag(fakeId, {
add: { key: "value" },
}),
).toThrow(VariableNotFoundError);
});
test("8.2: Special characters in tag keys/values", () => {
const variable = varStore.create("uwf/thread/", hashA);
const updated = varStore.tag(variable.id, {
add: { "env:region": "prod-us_west.2" },
});
expect(updated.tags).toEqual({ "env:region": "prod-us_west.2" });
});
test("8.3: Unicode in tag/label names", () => {
const variable = varStore.create("uwf/thread/", hashA);
const updated = varStore.tag(variable.id, {
add: { : "中文" },
addLabels: ["测试"],
});
expect(updated.tags).toEqual({ : "中文" });
expect(updated.labels).toContain("测试");
// Verify persistence
const retrieved = varStore.get(variable.id);
expect(retrieved?.tags).toEqual({ : "中文" });
expect(retrieved?.labels).toContain("测试");
});
test("8.4: Empty tag key or value", () => {
const variable = varStore.create("uwf/thread/", hashA);
// Empty key
const updated1 = varStore.tag(variable.id, {
add: { "": "value" },
});
expect(updated1.tags).toEqual({ "": "value" });
// Empty value
const updated2 = varStore.tag(variable.id, {
add: { key: "" },
});
expect(updated2.tags.key).toBe("");
});
test("8.5: Very long tag key/value", () => {
const variable = varStore.create("uwf/thread/", hashA);
const longKey = "k".repeat(1000);
const longValue = "v".repeat(1000);
const updated = varStore.tag(variable.id, {
add: { [longKey]: longValue },
});
expect(updated.tags[longKey]).toBe(longValue);
});
});
describe("Test Group 9: Database Integrity", () => {
test("9.1: Cascade delete for tags", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active", workflow: "solve-issue" },
});
varStore.delete(variable.id);
// Verify variable is deleted
const retrieved = varStore.get(variable.id);
expect(retrieved).toBeNull();
});
test("9.2: Cascade delete for labels", () => {
const variable = varStore.create("uwf/workflow/", hashA, {
labels: ["pinned", "archived"],
});
varStore.delete(variable.id);
const retrieved = varStore.get(variable.id);
expect(retrieved).toBeNull();
});
test("9.3: Tag update preserves other variable data", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active" },
});
varStore.tag(variable.id, {
add: { priority: "high" },
});
const retrieved = varStore.get(variable.id);
expect(retrieved?.id).toBe(variable.id);
expect(retrieved?.scope).toBe(variable.scope);
expect(retrieved?.value).toBe(variable.value);
expect(retrieved?.schema).toBe(variable.schema);
expect(retrieved?.created).toBe(variable.created);
});
});
describe("Test Group 10: Batch Operations and Atomicity", () => {
test("10.1: Atomic tag operations", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { status: "active", workflow: "solve-issue" },
});
const updated = varStore.tag(variable.id, {
add: { priority: "low" },
addLabels: ["archived"],
delete: ["status"],
});
expect(updated.tags).toEqual({
workflow: "solve-issue",
priority: "low",
});
expect(updated.labels).toContain("archived");
});
test("10.2: Rollback on conflict error", () => {
const variable = varStore.create("uwf/thread/", hashA, {
tags: { workflow: "solve-issue" },
});
expect(() =>
varStore.tag(variable.id, {
add: { priority: "high" },
addLabels: ["workflow"], // Conflict!
}),
).toThrow(TagLabelConflictError);
// Verify NO changes applied
const retrieved = varStore.get(variable.id);
expect(retrieved?.tags).toEqual({ workflow: "solve-issue" });
expect(retrieved?.labels).toEqual([]);
});
});
describe("Test Group 11: Integration Tests", () => {
test("11.1: Full workflow with tags and labels", async () => {
// Create with initial tags
const var1 = varStore.create("uwf/thread/", hashA, {
tags: { status: "active" },
});
await new Promise((resolve) => setTimeout(resolve, 10));
// Add more tags
varStore.tag(var1.id, {
add: { priority: "high", workflow: "solve-issue" },
});
// Add labels
varStore.tag(var1.id, {
addLabels: ["pinned"],
});
// Update variable value
const updated = varStore.update(var1.id, hashB);
// Verify tags/labels preserved
expect(updated.tags).toEqual({
status: "active",
priority: "high",
workflow: "solve-issue",
});
expect(updated.labels).toContain("pinned");
// Delete variable
varStore.delete(var1.id);
// Verify deletion
const retrieved = varStore.get(var1.id);
expect(retrieved).toBeNull();
});
test("11.2: Query with complex filtering", () => {
const var1 = varStore.create("uwf/thread/", hashA, {
tags: { status: "completed", priority: "high" },
labels: ["archived"],
});
varStore.create("uwf/thread/", hashB, {
tags: { status: "completed", priority: "low" },
});
varStore.create("uwf/workflow/", hashC, {
tags: { status: "completed", priority: "high" },
labels: ["archived"],
});
const results = varStore.list({
scope: "uwf/thread/",
tags: { status: "completed", priority: "high" },
labels: ["archived"],
});
expect(results).toHaveLength(1);
expect(results[0]?.id).toBe(var1.id);
});
});
});
+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();
});
});
+3 -8
View File
@@ -1,18 +1,13 @@
import type { Hash } from "./types.js";
/**
* ULID identifier (26-character Crockford Base32)
*/
export type VariableId = string;
/**
* Variable: mutable binding to an immutable CAS node
* Identified by composite key (name, schema)
*/
export type Variable = {
id: VariableId;
scope: string; // hierarchical path, must end with /
name: string; // variable name (unique per schema)
schema: Hash; // schema hash (part of composite key)
value: Hash; // CAS node hash
schema: Hash; // extracted from value's CAS node.type
created: number; // epoch ms
updated: number; // epoch ms
tags: Record<string, string>; // key-value pairs
@@ -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();
@@ -74,7 +78,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
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();
@@ -443,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
@@ -585,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();