Compare commits

...

84 Commits

Author SHA1 Message Date
xiaoju cb2041e29c docs: update README — ucas is primary CLI, json-cas deprecated
Also add list-meta and list-schema to command reference.
2026-06-01 05:56:54 +00:00
xiaoju 318df9b67f Merge pull request 'feat: add list-meta and list-schema commands with persistent meta index' (#91) from fix/90-list-meta-schema into main 2026-06-01 05:48:07 +00:00
xiaoju 8bfcdfef25 test: add E4 regression for list-schema vs list --type
Adds the missing E4 regression scenario from the test spec:
demonstrates that with multiple meta-schema versions, list-schema
returns schemas typed by older metas while list --type @schema
(resolving to the newest meta) does not.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-01 05:21:35 +00:00
xiaoju 55144d0e4a feat: add list-meta and list-schema commands with persistent meta index
Adds Store.listMeta() and Store.listSchemas() to expose meta-schema
discovery, backed by an in-memory metaSet (memory store) and a
persistent _index/_meta file (FS store). Surfaces both via new
json-cas list-meta and list-schema CLI commands wrapped in
@output/list-meta and @output/list-schema envelope schemas.

The FS store migrates from existing nodes when _meta is absent
(scanning self-referencing nodes) and preserves _meta on subsequent
opens. delete() removes affected hashes from the meta index and
persists the change.

Closes #90

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-01 05:10:25 +00:00
xingyue 8ac13e691e Merge pull request 'refactor: reorganize CLI E2E tests into tests/ by scenario' (#89) from feat/82-schema-p3 into main 2026-06-01 04:23:20 +00:00
xiaoju 1d08c1bf4d refactor: reorganize CLI E2E tests into tests/ by scenario
Move 4 monolithic test files (cli.test.ts, e2e.test.ts, template.test.ts,
var.test.ts) from src/ into tests/ directory, split by scenario:

  tests/helpers.ts           - shared utilities (envValue, stripVolatile)
  tests/put-get-has.test.ts  - basic CAS storage operations
  tests/verify-refs-walk.test.ts - graph traversal
  tests/schema-validation.test.ts - Issue #50 schema validation
  tests/alias.test.ts        - @ alias resolution
  tests/variable.test.ts     - var set/get/delete/list/tag
  tests/template.test.ts     - template set/get/list/delete
  tests/render.test.ts       - render + render -p
  tests/pipe.test.ts         - pipe composition + stdin input
  tests/gc.test.ts           - garbage collection
  tests/edge-cases.test.ts   - help, --store, error paths

All 516 tests pass (159 CLI + 357 core/fs).
2026-06-01 03:38:35 +00:00
xingyue 0d09e3cb80 Merge pull request 'feat: P3 JSON Schema support — not, contains, propertyNames, metadata (#82)' (#88) from feat/82-schema-p3 into main 2026-06-01 02:34:07 +00:00
xiaoju ca4fe8c3ac feat: P3 JSON Schema support — not, contains, propertyNames, metadata (#82)
Add 8 new schema keywords:

Combinators (with collectRefs support):
- not: negation schema
- contains: array must contain matching item

Constraints:
- propertyNames: constrain object key names

Metadata (no collectRefs impact):
- examples, readOnly, writeOnly, deprecated, $comment

Total supported keywords: 40 (of ~45 commonly used).
Only $ref/$defs/$id/$schema remain unsupported (P4, deferred).

516 tests, 0 fail.

Closes #82
2026-06-01 02:31:59 +00:00
xingyue 58f57b0dd8 Merge pull request 'feat: P2 JSON Schema support — allOf, if/then/else, patternProperties, prefixItems (#82)' (#87) from feat/82-schema-p2 into main 2026-06-01 02:15:35 +00:00
xiaoju edff831e87 feat: P2 JSON Schema support — allOf, if/then/else, patternProperties, prefixItems (#82)
Add 10 new schema keywords:

Combinators/conditionals (with collectRefs support):
- allOf: array of sub-schemas, all must match
- if/then/else: conditional validation
- patternProperties: regex-keyed property schemas
- prefixItems: tuple validation (per-position schemas)

Leaf constraints:
- multipleOf, minProperties, maxProperties, default

Updated: ALLOWED_SCHEMA_KEYS, isValidSchema(), collectRefs(),
meta-schema (bootstrap.ts), and schema-validation tests.

13 new tests: schema acceptance, validation, rejection,
and collectRefs traversal for allOf/patternProperties/prefixItems.

510 tests, 0 fail.

Refs #82
2026-06-01 02:10:37 +00:00
xingyue 054b5d9308 Merge pull request 'feat: put/hash support --pipe/-p to read JSON from stdin (#83)' (#85) from fix/83-put-hash-pipe into main 2026-06-01 01:54:28 +00:00
xingyue bdb77fd1e3 feat: put/hash support --pipe/-p to read JSON from stdin (#83) 2026-06-01 09:50:03 +08:00
xingyue b7fc03fa16 Merge pull request 'fix: CLI put @schema uses putSchema() for recursive validation (#82)' (#86) from fix/82-meta-schema-nested-validation into main 2026-06-01 01:39:49 +00:00
xiaoju 9367a4141a fix: CLI put @schema uses putSchema() for recursive validation (#82)
CLI 'ucas put @schema' now detects meta-schema target and routes
through putSchema() which uses isValidSchema() — a proper recursive
validator — instead of ajv against the meta-schema (which cannot
express recursive constraints for nested property sub-schemas).

This fixes the core issue where schemas with typed properties like
{"type":"string","minLength":1} were rejected by the CLI path.

Added CLI E2E test: put @schema with nested constraints (minLength,
maximum, uniqueItems).

494 tests, 0 fail.

Refs #82
2026-06-01 01:35:40 +00:00
xingyue bfec74fe8e Merge pull request 'feat: support P1 leaf JSON Schema constraints (#82)' (#84) from feat/82-schema-p1-leaf-constraints into main 2026-06-01 01:26:50 +00:00
xiaoju 70af05adf5 feat: support P1 leaf JSON Schema constraints (#82)
Add 10 new schema keywords to whitelist + meta-schema:
- numeric: minimum, maximum, exclusiveMinimum, exclusiveMaximum
- string: minLength, maxLength, pattern
- array: minItems, maxItems, uniqueItems

These are pure leaf constraints with no collectRefs() impact.
5 new tests covering validation, rejection, and nested usage.

Refs #82
2026-06-01 01:14:02 +00:00
xiaoju ceba0b5d43 chore: add prepublishOnly workspace:* guard
Checks for unresolved workspace:* dependencies before publish.
Changesets resolves these automatically, so normal release flow
passes through. Direct npm publish with workspace deps is blocked.
2026-05-31 23:43:51 +00:00
xiaoju a050160bee chore: remove prepublishOnly guards
Changesets already handles workspace:* → real version resolution
during publish. The guard scripts are unnecessary and block the
release flow.
2026-05-31 23:33:58 +00:00
xiaoju 515f4d32f9 chore: version 0.6.0 — unified envelope output (RFC #67) 2026-05-31 23:24:59 +00:00
xiaoju c03180e66c Merge pull request 'feat: wrap template commands with envelope, update docs (Phase 4)' (#81) from fix/72-template-envelope into main 2026-05-31 16:00:32 +00:00
xiaoju 9090456ed2 feat: wrap template commands with envelope, update docs (Phase 4)
Fixes #72
2026-05-31 16:00:21 +00:00
xiaoju 15ffff1fd4 Merge pull request 'feat: wrap refs/walk/gc/var with envelope (Phase 3)' (#80) from fix/71-refs-walk-var-envelope into main 2026-05-31 15:41:08 +00:00
xiaoju d2b9dee4b9 feat: wrap refs/walk/gc/var with {type,value} envelope (Phase 3)
Fixes #71
2026-05-31 15:41:02 +00:00
xiaoju 547c45829f Merge pull request 'feat: wrap simple commands with envelope (Phase 2)' (#79) from fix/70-simple-envelope into main 2026-05-31 15:29:54 +00:00
xiaoju d328fbe6e4 feat: wrap put/get/has/hash/verify/list with {type,value} envelope (Phase 2)
Fixes #70
2026-05-31 15:29:49 +00:00
xiaoju 11bb6b3e32 Merge pull request 'feat: register 18 @output/* schemas, default templates, wrapEnvelope (Phase 1c)' (#78) from fix/75-output-schemas into main 2026-05-31 15:14:49 +00:00
xiaoju 0dca8a5521 feat: register 18 @output/* schemas, default templates, wrapEnvelope (Phase 1c)
Fixes #75
2026-05-31 15:14:28 +00:00
xiaoju 5aad956c83 Merge pull request 'feat: remove cat/schema commands, add list --type, enhance verify (Phase 1b)' (#77) from fix/74-remove-cat-schema into main 2026-05-31 14:52:36 +00:00
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
xiaoju 7242588dd9 feat: implement RFC-20 Phase 3 GC integration
Implements garbage collection (GC) with mark-and-sweep algorithm:
- Mark phase: recursively walks references from all variable values (global, not scoped)
- Sweep phase: deletes unmarked CAS nodes
- Schema preservation: schemas referenced by reachable nodes are preserved
- Bootstrap preservation: self-referencing meta-schema always preserved

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

Implements: #23

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

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

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

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

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

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

Closes #22

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 07:38:06 +00:00
xiaonuo 263fe40146 Merge pull request 'chore: Phase 1 code style fixes and missing features' (#28) from fix/27-phase1-code-style-fixes into main 2026-05-30 06:51:34 +00:00
xiaoju aefd93c33e chore: Phase 1 code style fixes and missing features
Fixes #27

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

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

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

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

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

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

Fixes #21

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 05:57:05 +00:00
62 changed files with 14824 additions and 309 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}}}" }
+231
View File
@@ -0,0 +1,231 @@
name: "retrospect-workflow"
description: "Post-execution retrospective: analyze a completed thread, find inefficiencies, and improve the workflow definition."
roles:
analyst:
description: "Scans thread execution for anomalies and produces a findings report"
goal: "You are a workflow execution analyst. You review completed thread data to find inefficiencies, wasted effort, and procedure gaps."
capabilities:
- data-analysis
procedure: |
You receive a completed thread ID in your task prompt.
Phase 0 — Validation (must pass before any analysis):
1. Run `uwf step list <thread-id>` to get thread metadata including the workflow hash
2. Run `uwf workflow show <workflow-hash>` to get the workflow name
3. Verify the workflow exists locally: check `.workflows/<name>.yaml` in the current repo
- If NOT found: output $status=wrong_project with the workflow name. Do NOT proceed.
4. Compare the thread's workflow hash against the current registered version:
- Run `uwf workflow show <name>` to get the current hash
- If hashes differ: the thread ran on an older version. Note this — you will need to diff versions after analysis.
Phase 1 — Overview scan:
5. From the step list, compute a health signal for each step:
- Duration: flag if >2x the median of other steps
- Output tokens: flag if >2x the median
- Status flow: flag non-happy-path transitions (rejected, fix_code, fix_spec, hook_failed)
- Step count: flag if the same role appears more than expected (indicates loops)
6. If no anomalies found AND versions match: output $status=clean
7. If no anomalies found BUT versions differ:
- Diff the two workflow versions to check if any procedure changes are relevant
- If the current version already addresses potential concerns: output $status=clean with a note
- Otherwise: proceed to Phase 2
Phase 2 — Targeted deep-dive (only for flagged steps):
8. For each flagged step, run `uwf step show <hash>` to get the detail with turns
9. Analyze the turn sequence for:
- Repeated tool calls with the same or similar input (blind retries)
- Tool errors followed by no strategy change (same approach retried)
- Unnecessary exploration (reading files or running commands unrelated to the task)
- Hallucinated commands or flags (commands that don't exist or wrong syntax)
- Excessive turns before reaching the goal
10. For each finding, record:
- Which role and step hash
- What happened (specific turn indices and commands)
- Root cause hypothesis (procedure gap, missing pitfall, unclear instruction)
- Suggested fix (what to add/change in the procedure)
11. If versions differ: compare findings against the version diff.
Mark any finding that is already fixed in the current version as "resolved_in_current".
Only report findings that are NOT yet addressed.
Output a structured findings report. Set $status=clean if nothing actionable, $status=findings if unresolved issues exist, or $status=wrong_project if the workflow doesn't belong here.
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "clean" }
summary: { type: string }
required: [$status, summary]
- type: object
properties:
$status: { const: "findings" }
report: { type: string }
targetWorkflow: { type: string }
required: [$status, report, targetWorkflow]
- type: object
properties:
$status: { const: "wrong_project" }
workflowName: { type: string }
required: [$status, workflowName]
proposer:
description: "Translates findings into concrete workflow edits"
goal: "You are a workflow improvement proposer. You read the analyst's findings and produce specific, minimal edits to the workflow YAML."
capabilities:
- planning
procedure: |
1. Read the analyst's findings report from your task prompt
2. Locate the target workflow YAML:
- Workflow definitions live in the WORKFLOW ENGINE repo (where `uwf` is developed), NOT in the repo that was analyzed.
- Find it via: `uwf workflow show <targetWorkflow> --format yaml` to read the current definition
- The physical file is `.workflows/<targetWorkflow>.yaml` in the workflow engine repo
- Use `git rev-parse --show-toplevel` in the current directory to find the workflow engine repo root
3. Read the current workflow YAML to understand existing procedures
4. For each finding, draft a minimal edit:
- Prefer adding a pitfall note or clarifying instruction over restructuring
- If a procedure step is ambiguous, make it explicit
- If a tool usage pattern is wrong, add a "Do NOT" or "IMPORTANT" note
- Keep edits surgical — don't rewrite procedures that work fine
5. Check if existing tests need updating (search for test files referencing the workflow)
6. Produce a change plan as CAS text node via `uwf cas put-text "<plan>"`
The plan should list each edit with:
- File path
- What to change (old text → new text, or addition)
- Why (linked to which finding)
- Any test updates needed
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- type: object
properties:
$status: { const: "no_action" }
reason: { type: string }
required: [$status, reason]
developer:
description: "Applies the proposed workflow edits"
goal: "You are a developer agent. You apply workflow YAML edits and update related tests."
capabilities:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The workflow definitions live in THIS repo (the workflow engine), not the repo that was analyzed.
Before starting any work, set up an isolated worktree:
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
2. `git fetch origin` to get latest refs
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
4. `cd .worktrees/retrospect/<short-slug> && bun install`
5. ALL subsequent work must happen inside the worktree directory.
Then apply changes:
6. Read the change plan from CAS: `uwf cas get <plan hash>`
7. Apply each edit from the plan to the workflow YAML
8. Update or add tests as specified in the plan
9. Run `bun run build` and `bun test` to verify
10. Run `bun run check` for lint
11. Commit with message: `improve: <workflow-name> — <brief summary>`
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- type: object
properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Reviews the workflow edits for correctness"
goal: "You are a reviewer. You verify that workflow edits are minimal, correct, and actually address the findings."
capabilities:
- code-review
procedure: |
The worktree path is provided in your task prompt. cd into it first.
Review criteria:
1. Each edit must trace back to a specific finding — no drive-by changes
2. Edits should be minimal — don't rewrite working procedures
3. New pitfall notes or instructions must be clear and actionable
4. Tests must be updated if assertions changed
5. `bun run build` and `bun test` must pass
6. `bunx biome check` must pass
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- type: object
properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
required: [$status, comments, worktree]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR."
capabilities: []
procedure: |
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message: `git commit -m "improve: <workflow> — <summary>"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
- IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files. cd to the repo root first.
- Do NOT pass `--repo` — let tea auto-detect from the main repo's git remote.
- PR description must include: What / Why / Findings / Changes sections
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- type: object
properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
analyst:
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
wrong_project: { role: "$END", prompt: "Thread uses workflow '{{{workflowName}}}' which does not exist in this project. Run retrospect from the correct repo." }
proposer:
no_action: { role: "$END", prompt: "No actionable changes needed: {{{reason}}}." }
ready: { role: "developer", prompt: "Apply the change plan (CAS hash: {{{plan}}}) to the workflow definitions in this repo." }
developer:
done: { role: "reviewer", prompt: "Review workflow edits on branch {{{branch}}} at {{{worktree}}}." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in {{{worktree}}}." }
approved: { role: "committer", prompt: "Approved. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow improved." }
+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]
+51 -19
View File
@@ -32,7 +32,7 @@ Use the in-memory store for tests and embedded apps, the filesystem store for pe
|-------|---------|------|
| Core | `@uncaged/json-cas` | Hashing, schemas, stores, verify, bootstrap |
| Storage | `@uncaged/json-cas-fs` | Filesystem-backed `Store` |
| CLI | `@uncaged/cli-json-cas` | `json-cas` command-line tool |
| CLI | `@uncaged/cli-json-cas` | `ucas` command-line tool |
## Packages
@@ -40,7 +40,7 @@ Use the in-memory store for tests and embedded apps, the filesystem store for pe
|---------|-------------|------|
| [`@uncaged/json-cas`](packages/json-cas/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
| [`@uncaged/json-cas-fs`](packages/json-cas-fs/README.md) | Filesystem-backed CAS store | lib |
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`json-cas` binary) | cli |
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`ucas` binary) | cli |
## Quick Start
@@ -88,30 +88,62 @@ Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/
## CLI Reference
Binary: `json-cas` (from `@uncaged/cli-json-cas`). Default store: `~/.uncaged/json-cas`.
Binary: `ucas` (from `@uncaged/cli-json-cas`; legacy alias `json-cas` is deprecated). Default store:
`~/.uncaged/json-cas`. The store is auto-created and bootstrapped on first use — there is
no `init`/`bootstrap` command, and schemas are ordinary `@schema`-typed nodes (`ucas put
@schema file.json`), so there is no `schema` subcommand.
### Envelope format
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
of the command's `@output/*` result schema and `value` is the command payload. This makes
output self-describing and pipeable: feed any envelope into `render -p` to render its
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
raw (non-envelope) text.
```jsonc
// ucas has <hash>
{ "type": "AYHQD2YA9G667", "value": true }
```
```
Usage: json-cas [--store <path>] [--json] <command> [args]
Usage: ucas [--store <path>] [--json] <command> [args]
Commands:
init Create store dir and write bootstrap seed
bootstrap Write meta-schema seed, print hash
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
schema validate <hash> Validate node against its schema
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
Commands (all emit a { type, value } envelope unless noted):
put <type-hash> <file.json> Store node (value = hash) (@output/put)
get <hash> Node payload + metadata (@output/get)
has <hash> Existence boolean (@output/has)
verify <hash> ok / corrupted / invalid (@output/verify)
refs <hash> Direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text (raw output)
render --pipe/-p [options] Render a piped envelope (raw output)
list --type <hash-or-alias> Hashes for a type (value = list) (@output/list)
list-meta Meta-schema hashes (@output/list-meta)
list-schema All schema hashes (@output/list-schema)
var set|get|delete|tag|list ... Variable CRUD (@output/var-*)
template set|get|list|delete ... Output-template CRUD (@output/template-*)
gc Garbage collection (@output/gc)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--json Compact JSON output
--pipe, -p Read a { type, value } envelope from stdin for render
```
### Pipe examples
```bash
# Store a node, then render the stored content (the put envelope's hash is
# a cas_ref, so render -p dereferences and renders it):
ucas put @schema ./schemas/item.json | ucas render -p
# Render garbage-collection stats:
ucas gc | ucas render -p
# List every schema, then consume the envelope's value array with jq:
ucas list --type @schema | jq -r '.value[]'
```
## Development
+10
View File
@@ -10,6 +10,7 @@
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0",
"ulidx": "^2.4.1",
},
},
"packages/cli-json-cas": {
@@ -29,6 +30,7 @@
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
"liquidjs": "^10.27.0",
"xxhash-wasm": "^1.1.0",
},
},
@@ -140,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=="],
@@ -200,6 +204,10 @@
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
"liquidjs": ["liquidjs@10.27.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
@@ -282,6 +290,8 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ulidx": ["ulidx@2.4.1", "", { "dependencies": { "layerr": "^3.0.0" } }, "sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+2 -1
View File
@@ -9,7 +9,8 @@
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"ulidx": "^2.4.1"
},
"scripts": {
"build": "tsc --build packages/json-cas packages/json-cas-fs",
+28
View File
@@ -1,5 +1,33 @@
# @uncaged/cli-json-cas
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.6.0
- @uncaged/json-cas-fs@0.6.0
## 0.5.3
### Patch Changes
+80 -31
View File
@@ -4,7 +4,9 @@ CLI tool for json-cas stores.
## Overview
`@uncaged/cli-json-cas` provides the `json-cas` command for managing a filesystem-backed store: bootstrap, schema registration, node CRUD, integrity checks, reference listing, and graph walks. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
`@uncaged/cli-json-cas` provides the `json-cas` command (also aliased `ucas`) for managing a filesystem-backed store: node CRUD, integrity checks, reference listing, graph walks, variables, and output templates. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
The store is **auto-created and bootstrapped** on first use, so there is no `init`/`bootstrap` command. Schemas are ordinary `@schema`-typed nodes — register one with `ucas put @schema file.json` and list them with `ucas list --type @schema`; there is no dedicated `schema` subcommand.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
@@ -37,48 +39,95 @@ Usage: json-cas [--store <path>] [--json] <command> [args]
| Flag | Description |
|------|-------------|
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
| `--json` | Compact JSON output for commands that print JSON |
| `--var-db <path>` | Variable database path (default: `<store>/variables.db`) |
| `--json` | Compact (single-line) JSON output |
### Envelope format
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
of the command's `@output/*` result schema and `value` is the command payload. The output
is therefore self-describing and pipeable: feed any envelope into `render -p` to render its
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
raw, non-envelope text.
```jsonc
// json-cas has <hash>
{ "type": "AYHQD2YA9G667", "value": true }
// json-cas template set <schema-hash> --inline "Hi {{ payload.name }}"
{ "type": "9YJZ09DDAYAWR", "value": { "schemaHash": "7XX5H51CVD9H0", "contentHash": "FC8WACA792B6F" } }
```
### Commands
| Command | Description |
|---------|-------------|
| `init` | Create store directory and write bootstrap seed; prints meta hash |
| `bootstrap` | Write meta-schema seed into existing store; prints hash |
| `schema put <file.json>` | Register schema from file; prints type hash |
| `schema get <type-hash>` | Print schema JSON |
| `schema list` | List all schemas (`hash name`) |
| `schema validate <hash>` | Validate node against its schema; prints `valid` / `invalid` |
| `put <type-hash> <file.json>` | Store node; prints content hash |
| `get <hash>` | Print full node as JSON |
| `has <hash>` | Print `true` or `false` |
| `verify <hash>` | Verify integrity; prints `ok` or `corrupted` |
| `refs <hash>` | Print direct `cas_ref` targets (one per line) |
| `walk <hash>` | BFS traversal; one hash per line |
| `walk <hash> --format tree` | Tree-formatted traversal |
| `hash <type-hash> <file.json>` | Compute hash without storing |
| `cat <hash>` | Print node JSON |
| `cat <hash> --payload` | Print payload only |
| Command | Envelope `value` | Result schema |
|---------|------------------|---------------|
| `put <type-hash> <file.json>` | stored node hash (string) | `@output/put` |
| `get <hash>` | `{ type, payload, timestamp }` | `@output/get` |
| `has <hash>` | boolean | `@output/has` |
| `verify <hash>` | `ok` / `corrupted` / `invalid` | `@output/verify` |
| `refs <hash>` | hashes (string[]) | `@output/refs` |
| `walk <hash> [--format tree]` | hashes (string[]) or tree string | `@output/walk` |
| `hash <type-hash> <file.json>` | computed hash (string) | `@output/hash` |
| `render <hash> [options]` | raw text (no envelope) | — |
| `render --pipe/-p [options]` | raw text from piped envelope | — |
| `list --type <hash-or-alias>` | hashes (string[]) | `@output/list` |
| `var set <name> <hash> [--tag ...]` | variable object | `@output/var-set` |
| `var get <name> --schema <hash>` | variable object | `@output/var-get` |
| `var delete <name> [--schema <hash>]` | variable or variable[] | `@output/var-delete` |
| `var tag <name> --schema <hash> <ops...>` | variable object | `@output/var-tag` |
| `var list [prefix] [--schema <hash>] [--tag ...]` | variable[] | `@output/var-list` |
| `template set <schema-hash> <file> \| --inline <text>` | `{ schemaHash, contentHash }` | `@output/template-set` |
| `template get <schema-hash>` | template content (string) | `@output/template-get` |
| `template list` | `{ schemaHash, contentHash }[]` | `@output/template-list` |
| `template delete <schema-hash>` | `{ deleted: boolean }` | `@output/template-delete` |
| `gc` | `{ total, reachable, collected, scanned }` | `@output/gc` |
### Examples
```bash
# Initialize default store at ~/.uncaged/json-cas
json-cas init
# Register a schema (schemas are plain @schema nodes) and store a payload
json-cas put @schema ./schemas/item.json
# → { "type": "...", "value": "0123456789ABC" } (the schema's type hash)
# Use a custom store path
json-cas --store ./data/cas bootstrap
# Register a schema and store a payload
json-cas schema put ./schemas/item.json
# → prints type hash, e.g. 0123456789ABCD
json-cas put 0123456789ABCD ./payloads/item.json
# → prints content hash
json-cas put 0123456789ABC ./payloads/item.json
# → { "type": "...", "value": "<content-hash>" }
json-cas get <content-hash> --json
json-cas verify <content-hash>
json-cas walk <content-hash> --format tree
# List every registered schema, then extract the hashes with jq
json-cas list --type @schema | jq -r '.value[]'
```
### Pipe composition
Because every command shares the `{ type, value }` envelope, output composes directly into
`render -p`:
```bash
# put emits a cas_ref hash envelope; render -p dereferences and renders the node
json-cas put @schema ./schemas/item.json | json-cas render -p
# render gc statistics
json-cas gc | json-cas render -p
# render every schema referenced by a list result
json-cas list --type @schema | json-cas render -p
```
### Templates
`template` commands manage the LiquidJS template bound to a schema (stored as a
`@ucas/template/text/<schema-hash>` variable). `render <hash>` uses the template registered
for the node's type, falling back to YAML when none exists.
```bash
# Bind a template to a schema, then render a node of that type
json-cas template set 0123456789ABC --inline "Item: {{ payload.name }}"
json-cas render <content-hash>
# → Item: Widget
```
## Internal Structure
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-json-cas",
"version": "0.5.3",
"version": "0.6.0",
"type": "module",
"bin": {
"json-cas": "./src/index.ts",
@@ -8,10 +8,10 @@
},
"scripts": {
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3"
"@uncaged/json-cas": "^0.6.0",
"@uncaged/json-cas-fs": "^0.6.0"
}
}
-33
View File
@@ -1,33 +0,0 @@
import { describe, expect, test } from "bun:test";
import { resolve } from "node:path";
const pkgPath = resolve(import.meta.dir, "../package.json");
describe("ucas command alias", () => {
test("T1: ucas bin entry exists in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe("./src/index.ts");
});
test("T2: json-cas bin entry is preserved in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin["json-cas"]).toBe("./src/index.ts");
});
test("T3: ucas command is executable and shows help", async () => {
const entrypoint = resolve(import.meta.dir, "index.ts");
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
test("T4: both commands point to the same entrypoint", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,249 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
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]
All JSON commands emit a { type, value } envelope. The type is the hash of the
command's @output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (cas_ref hashes are expanded).
Commands:
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
list-meta List meta-schema hashes (value=string[]) (@output/list-meta)
list-schema List all schema hashes (value=string[]) (@output/list-schema)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@output/var-tag)
template set <schema-hash> <file> | --inline <text> Set template for schema (@output/template-set)
template get <schema-hash> Get template content (value=string) (@output/template-get)
template list List all templates (@output/template-list)
template delete <schema-hash> Delete template for schema (@output/template-delete)
gc Run garbage collection (@output/gc)
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 from stdin (put/hash: raw JSON payload; render: { type, value } envelope)"
`;
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
{
"type": "5KVHSESSX7N96",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {},
"value": "6KZ930XYK2MHB",
},
}
`;
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "7D4R2MJ1EYF08",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {},
"value": "6KZ930XYK2MHB",
},
}
`;
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
{
"type": "E8158M25YNR9M",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {},
"value": "6KZ930XYK2MHB",
},
],
}
`;
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
{
"type": "E8158M25YNR9M",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {},
"value": "6KZ930XYK2MHB",
},
],
}
`;
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
{
"type": "5KVHSESSX7N96",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {},
"value": "AE80669JYG4K2",
},
}
`;
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
{
"type": "2WX4XRYSACRWR",
"value": {
"labels": [
"important",
],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {
"env": "prod",
},
"value": "6KZ930XYK2MHB",
},
}
`;
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "E8158M25YNR9M",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {
"env": "prod",
},
"value": "6KZ930XYK2MHB",
},
],
}
`;
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
{
"type": "E8158M25YNR9M",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {
"env": "prod",
},
"value": "6KZ930XYK2MHB",
},
],
}
`;
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
{
"type": "2WX4XRYSACRWR",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {
"env": "prod",
},
"value": "6KZ930XYK2MHB",
},
}
`;
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
{
"type": "F89626QEK09YY",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "8WAZV39SD724T",
"tags": {
"env": "prod",
},
"value": "6KZ930XYK2MHB",
},
],
}
`;
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=8WAZV39SD724T"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
{
"type": "A8RGY9B1JSCDJ",
"value": {
"contentHash": "0359SHMP7VBFD",
"schemaHash": "8WAZV39SD724T",
},
}
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
{
"type": "64FP3TWKK69YH",
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
}
`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
{
"type": "BE20H482GBNM3",
"value": [
{
"contentHash": "0359SHMP7VBFD",
"schemaHash": "8WAZV39SD724T",
},
],
}
`;
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
{
"type": "0FXB2GS8Y9R1R",
"value": {
"deleted": true,
},
}
`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 8WAZV39SD724T"`;
@@ -0,0 +1,14 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"type": "CE40D09H75NFZ",
"value": {
"payload": {
"age": 30,
"name": "Alice",
},
"type": "8WAZV39SD724T",
},
}
`;
@@ -0,0 +1,5 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
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!"`;
@@ -0,0 +1,3 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
@@ -0,0 +1,24 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
{
"type": "BZ31JDDWX2AWH",
"value": "ok",
}
`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
"{
"type": "DG8SAB75PV9P7",
"value": []
}"
`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
"{
"type": "EVHG7Q7FK83H0",
"value": [
"6KZ930XYK2MHB"
]
}"
`;
+187
View File
@@ -0,0 +1,187 @@
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";
// ---- @ Alias Resolution Tests ----
let testDir: string;
let storePath: string;
let cliPath: string;
beforeEach(() => {
// Create unique temp directory for each test
testDir = join(
tmpdir(),
`json-cas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
// Clean up test directory
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
/**
* Run CLI command and return stdout, stderr, and exit code
*/
async function runCliAlias(...args: string[]): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--store", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
}
/** Extract the `value` field from a { type, value } envelope JSON string. */
function envValue(json: string): unknown {
return (JSON.parse(json.trim()) as { value: unknown }).value;
}
describe("@ Alias Resolution - put", () => {
test("ucas put @string <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify("hello world"));
const { stdout, stderr, exitCode } = await runCliAlias(
"put",
"@string",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
// Should output an envelope whose value is a valid hash (13 chars)
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @number <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "42");
const { stdout, exitCode } = await runCliAlias(
"put",
"@number",
payloadFile,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @object <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ foo: "bar" }));
const { stdout, exitCode } = await runCliAlias(
"put",
"@object",
payloadFile,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @invalid <file> should fail", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "{}");
const { stderr, exitCode } = await runCliAlias(
"put",
"@invalid",
payloadFile,
);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
});
test("ucas put @schema with nested type constraints should succeed", async () => {
await runCliAlias("init");
const schemaFile = join(testDir, "constrained-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 50 },
age: { type: "number", minimum: 0, maximum: 150 },
tags: {
type: "array",
items: { type: "string" },
minItems: 1,
uniqueItems: true,
},
},
required: ["name"],
}),
);
const { stdout, stderr, exitCode } = await runCliAlias(
"put",
"@schema",
schemaFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
describe("@ Alias Resolution - hash", () => {
test("ucas hash @string <file> should compute hash without storing", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify("test"));
const { stdout, stderr, exitCode } = await runCliAlias(
"hash",
"@string",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
@@ -0,0 +1,436 @@
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";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
const pkgPath = resolve(import.meta.dir, "../package.json");
// --- ucas command alias tests (from cli.test.ts) ---
describe("ucas command alias", () => {
test("T1: ucas bin entry exists in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe("./src/index.ts");
});
test("T2: json-cas bin entry is preserved in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin["json-cas"]).toBe("./src/index.ts");
});
test("T3: ucas command is executable and shows help", async () => {
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
test("T4: both commands point to the same entrypoint", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
});
});
// --- e2e Phase 7: Edge Cases ---
describe("Phase 7: Edge Cases", () => {
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
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 };
}
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
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");
});
});
// --- e2e Phase 3: Variable System (edge cases from e2e.test.ts) ---
describe("Phase 3: Variable System", () => {
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
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 };
}
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
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: node2Out } = await runCli(["put", typeHash, node2File]);
const node2Hash = envValue(node2Out) as string;
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
node2Hash,
]);
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();
});
});
// --- e2e Phase 4: Template System ---
describe("Phase 4: Template System", () => {
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
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 };
}
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
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(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.2 template get returns template text", async () => {
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe(
"Name: {{ payload.name }}, Age: {{ payload.age }}",
);
expect(stripVolatile(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(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.4 template delete removes template", async () => {
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(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();
});
});
+125
View File
@@ -0,0 +1,125 @@
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";
import { envValue } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
// Set a var referencing the node so it survives GC
await runCli(["var", "set", "gc-test/ref", nodeHash]);
});
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 };
}
// ---- Phase 6: GC ----
describe("Phase 6: GC", () => {
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 = envValue(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 | render -p renders the gc stats", async () => {
const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]);
expect(gcExit).toBe(0);
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"render",
"--pipe",
],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(gcOut);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
expect(exitCode).toBe(0);
// gc value is an object { total, reachable, collected, scanned }
expect(stdout).toContain("total:");
});
test("6.3 gc preserves node referenced by a var", async () => {
const { exitCode } = await runCli(["gc"]);
expect(exitCode).toBe(0);
const { stdout } = await runCli(["has", nodeHash]);
expect(envValue(stdout)).toBe(true);
});
test("6.4 gc reclaims orphan node", async () => {
const orphanFile = join(tmpStore, "orphan.json");
writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 }));
const { stdout: orphanOut } = await runCli(["put", typeHash, orphanFile]);
const orphanHash = envValue(orphanOut) as string;
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
expect(envValue(beforeGc)).toBe(true);
await runCli(["gc"]);
const { stdout: afterGc } = await runCli(["has", orphanHash]);
expect(envValue(afterGc)).toBe(false);
});
});
+107
View File
@@ -0,0 +1,107 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
export {
join,
mkdirSync,
mkdtempSync,
readFileSync,
resolve,
rmSync,
tmpdir,
writeFileSync,
};
export const entrypoint = resolve(import.meta.dir, "../src/index.ts");
export const pkgPath = resolve(import.meta.dir, "../package.json");
/** Extract the `value` field from a { type, value } envelope JSON string. */
export function envValue(json: string): unknown {
return (JSON.parse(json.trim()) as { value: unknown }).value;
}
/**
* Register a schema directly via the library (CLI schema put was removed).
* Returns the type hash.
*/
export async function putSchemaFile(
storePath: string,
schemaFilePath: string,
): Promise<string> {
const store = await openFsStore(storePath);
const schema = JSON.parse(
readFileSync(schemaFilePath, "utf-8"),
) as JSONSchema;
const hash = await putSchema(store, schema);
return hash;
}
/**
* Run CLI command. Accepts either a string[] or ...string[] (rest args).
* If first arg is an array, uses that as args. Otherwise treats all args as the command.
*/
export async function runCli(
args: string[],
storePath?: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const finalArgs = storePath
? ["bun", entrypoint, "--store", storePath, ...args]
: ["bun", entrypoint, ...args];
const proc = Bun.spawn(finalArgs, {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { stdout, stderr, exitCode };
}
export async function runCliWithStdin(
args: string[],
storePath: string,
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const finalArgs = ["bun", entrypoint, "--store", storePath, ...args];
const proc = Bun.spawn(finalArgs, {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
});
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { stdout, stderr, exitCode };
}
/**
* Parse JSON and strip volatile fields (timestamp, created, updated)
* so snapshots are stable across runs.
*/
export 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));
}
@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BOOTSTRAP_STORE } from "@uncaged/json-cas";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
import { envValue, runCli } from "./helpers.js";
let storePath: string;
beforeEach(() => {
storePath = mkdtempSync(join(tmpdir(), "json-cas-list-meta-schema-"));
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
rmSync(storePath, { recursive: true, force: true });
});
describe("list-meta CLI command", () => {
test("E1. list-meta on bootstrapped store contains exactly the meta-schema hash", async () => {
// First, get @schema hash by calling has on it (also triggers bootstrap)
const { stdout: hashOut, exitCode: hashCode } = await runCli(
["hash", "@schema", "--pipe"],
storePath,
);
// ensure bootstrap by running a no-op command:
void hashOut;
void hashCode;
// Bootstrap fully via 'list --type @schema'
const { stdout: schemaListOut } = await runCli(
["list", "--type", "@schema"],
storePath,
);
const schemaList = envValue(schemaListOut) as string[];
expect(Array.isArray(schemaList)).toBe(true);
const { stdout, stderr, exitCode } = await runCli(["list-meta"], storePath);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(Array.isArray(parsed.value)).toBe(true);
expect(parsed.value).toHaveLength(1);
expect(parsed.value[0]).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("E1. --json flag yields compact JSON", async () => {
const { stdout, exitCode } = await runCli(
["--json", "list-meta"],
storePath,
);
expect(exitCode).toBe(0);
// compact = no newlines/spaces between fields
expect(stdout.trim()).not.toContain("\n ");
});
});
describe("list-schema CLI command", () => {
test("E2. list-schema on bootstrapped store includes meta-schema and built-ins", async () => {
const { stdout, stderr, exitCode } = await runCli(
["list-schema"],
storePath,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(Array.isArray(parsed.value)).toBe(true);
// meta + 5 primitive + 20 output = 26
expect(parsed.value.length).toBeGreaterThanOrEqual(6);
});
});
describe("usage help", () => {
test("E3. printing usage includes list-meta and list-schema lines", async () => {
const { stdout, exitCode } = await runCli([], storePath);
expect(exitCode).toBe(0);
expect(stdout).toContain("list-meta");
expect(stdout).toContain("list-schema");
});
});
describe("F1. output schemas registered", () => {
test("@output/list-meta and @output/list-schema schemas exist", async () => {
const { stdout, exitCode } = await runCli(["list-meta"], storePath);
expect(exitCode).toBe(0);
const parsed = JSON.parse(stdout) as { type: string };
// type hash references the @output/list-meta schema, must be retrievable
const { stdout: getOut, exitCode: getCode } = await runCli(
["get", parsed.type],
storePath,
);
expect(getCode).toBe(0);
const node = envValue(getOut) as {
payload: { title?: string };
};
expect(node.payload.title).toBe("ucas list-meta result");
const { stdout: schemaOut } = await runCli(["list-schema"], storePath);
const schemaParsed = JSON.parse(schemaOut) as { type: string };
const { stdout: getSchOut, exitCode: getSchCode } = await runCli(
["get", schemaParsed.type],
storePath,
);
expect(getSchCode).toBe(0);
const schNode = envValue(getSchOut) as {
payload: { title?: string };
};
expect(schNode.payload.title).toBe("ucas list-schema result");
});
});
describe("E4. list-schema vs list --type with multiple meta-schema versions", () => {
test("list-schema includes schemas typed by older meta-schemas; list --type @schema does not", async () => {
// Set up two distinct meta-schemas via the library
const store = await openFsStore(storePath);
const m1 = await store[BOOTSTRAP_STORE]({
type: "object",
title: "meta-v1",
});
const m2 = await store[BOOTSTRAP_STORE]({
type: "object",
title: "meta-v2",
});
// schema typed by older meta M1
const sM1 = await store.put(m1, { type: "string" });
expect(m1).not.toBe(m2);
// CLI: list-schema must include sM1
const { stdout: lsOut, exitCode: lsCode } = await runCli(
["list-schema"],
storePath,
);
expect(lsCode).toBe(0);
const lsValue = envValue(lsOut) as string[];
expect(lsValue).toContain(sM1);
expect(lsValue).toContain(m1);
expect(lsValue).toContain(m2);
// CLI: list --type <M2 hash> must NOT include sM1
const { stdout: ltOut, exitCode: ltCode } = await runCli(
["list", "--type", m2],
storePath,
);
expect(ltCode).toBe(0);
const ltValue = envValue(ltOut) as string[];
expect(ltValue).not.toContain(sM1);
// list-schema.value.length > list --type <newest>.value.length
expect(lsValue.length).toBeGreaterThan(ltValue.length);
});
});
+214
View File
@@ -0,0 +1,214 @@
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";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
// Set up template for render tests
const tmplFile = join(tmpStore, "render-template.liquid");
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
await runCli(["template", "set", typeHash, tmplFile]);
});
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 };
}
async function runCliWithStdin(
args: string[],
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(stdin);
proc.stdin.end();
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 };
}
// ---- Phase 8: Pipe Composition ----
describe("Phase 8: Pipe Composition", () => {
test("8.1 put | render -p expands the stored hash to its content", async () => {
const nodeFile = join(tmpStore, "pipe-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Bob", age: 42 }));
const { stdout: putOut, exitCode: putExit } = await runCli([
"put",
typeHash,
nodeFile,
]);
expect(putExit).toBe(0);
// The put envelope value is a cas_ref hash; render -p dereferences it and
// renders the stored node's payload.
const { stdout, exitCode } = await runCliWithStdin(
["render", "--pipe"],
putOut,
);
expect(exitCode).toBe(0);
expect(stdout).toContain("Bob");
});
test("8.3 list --type @schema emits a parseable envelope of hashes", async () => {
const { stdout, exitCode } = await runCli(["list", "--type", "@schema"]);
expect(exitCode).toBe(0);
// Downstream consumers (jq, etc.) read the `value` array of hashes.
const value = envValue(stdout) as string[];
expect(Array.isArray(value)).toBe(true);
for (const hash of value) {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("8.4 list --type @schema | render -p expands the schema list", async () => {
const { stdout: listOut } = await runCli(["list", "--type", "@schema"]);
// list result items are cas_ref hashes; render -p dereferences each one
// and renders the schema contents.
const { stdout, exitCode } = await runCliWithStdin(
["render", "--pipe"],
listOut,
);
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
test("8.5 render <hash> uses a registered template", async () => {
// Register a template for the schema, then render a fresh node by hash.
const tmplFile = join(tmpStore, "pipe-render.liquid");
writeFileSync(tmplFile, "Person: {{ payload.name }} ({{ payload.age }})");
const { exitCode: setExit } = await runCli([
"template",
"set",
typeHash,
tmplFile,
]);
expect(setExit).toBe(0);
const nodeFile = join(tmpStore, "pipe-render-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Carol", age: 25 }));
const { stdout: putOut } = await runCli(["put", typeHash, nodeFile]);
const freshHash = envValue(putOut) as string;
const { stdout, exitCode } = await runCli(["render", freshHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Person: Carol (25)");
});
});
// ---- Phase 9: Put/Hash Pipe Input ----
describe("Phase 9: Put/Hash Pipe Input", () => {
test("9.1 put -p reads JSON from stdin and stores node", async () => {
const payload = JSON.stringify({ name: "PipeAlice", age: 99 });
const { stdout, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
payload,
);
expect(exitCode).toBe(0);
const hash = envValue(stdout);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Verify stored correctly
const { stdout: getOut } = await runCli(["get", hash as string]);
expect(getOut).toContain("PipeAlice");
});
test("9.2 hash -p reads JSON from stdin and computes hash without storing", async () => {
const payload = JSON.stringify({ name: "PipeBob", age: 55 });
const { stdout, exitCode } = await runCliWithStdin(
["hash", typeHash, "-p"],
payload,
);
expect(exitCode).toBe(0);
const hash = envValue(stdout);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Should NOT be stored
const { exitCode: hasExit, stdout: hasOut } = await runCli([
"has",
hash as string,
]);
expect(hasExit).toBe(0);
expect(envValue(hasOut)).toBe(false);
});
test("9.3 put -p with file arg errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "some-file.json", "-p"],
"{}",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Cannot use --pipe/-p with a file argument");
});
test("9.4 put -p with empty stdin errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
"",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("No input on stdin");
});
test("9.5 put -p with invalid JSON errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
"not json",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Invalid JSON on stdin");
});
});
@@ -0,0 +1,101 @@
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";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
});
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 };
}
describe("Phase 1: CAS Core", () => {
test("1.1 init + put with @object bootstraps store", async () => {
expect(typeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("1.5 put returns node hash", async () => {
expect(nodeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
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(envValue(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(envValue(stdout)).toBe(false);
});
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(envValue(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(envValue(stdout)).toContain(nodeHash);
});
});
+521
View File
@@ -0,0 +1,521 @@
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";
import { bootstrap } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { envValue, putSchemaFile, runCli, runCliWithStdin } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
// --- Standalone render tests from cli.test.ts ---
describe("ucas render command", () => {
test("R1: render requires hash argument", async () => {
const { exitCode, stderr } = await runCli(["render"]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Usage");
});
test("R2: render with missing hash shows error", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const { exitCode, stderr } = await runCli(
["render", "ZZZZZZZZZZZZZ"],
tmpStore,
);
// Missing hash should exit with error
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Node not found");
expect(stderr).toContain("ZZZZZZZZZZZZZ");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R3: render with invalid numeric flag fails", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const { exitCode, stderr } = await runCli(
["render", "AAAAAAAAAAAAA", "--resolution", "invalid"],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("valid number");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
// --- e2e Phase 5: Render ---
describe("Phase 5: Render", () => {
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
async function runCliE2e(
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 };
}
async function runCliE2eWithStdin(
args: string[],
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(stdin);
proc.stdin.end();
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 };
}
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCliE2e(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
// Register template for render tests
const tmplFile = join(tmpStore, "render-template.liquid");
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
await runCliE2e(["template", "set", typeHash, tmplFile]);
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
test("5.1 render fills payload variables", async () => {
const { stdout, exitCode } = await runCliE2e(["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 runCliE2e([
"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 runCliE2e(["render", "ZZZZZZZZZZZZZ"]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Node not found");
expect(stderr).toContain("ZZZZZZZZZZZZZ");
});
});
// --- Suite 6: CLI Integration with Templates (from cli.test.ts) ---
describe("Suite 6: CLI Integration with Templates", () => {
test("6.1 CLI with Template (Default Parameters)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
// Initialize store
await runCli(["init"], tmpStore);
// Create schema
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Create node
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice" }));
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// Create template file (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(templateFile, JSON.stringify("Hello {{ payload.name }}!"));
const { stdout: tmplOut } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
const tmplHash = envValue(tmplOut) as string;
// Register template
await runCli(
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
// Render with template
const { stdout: output, exitCode } = await runCli(
["render", nodeHash],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toBe("Hello Alice!");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.2 CLI with Template + Custom Decay", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Create schema with child ref
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
value: { type: "string" },
child: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Create child node
const childFile = join(tmpStore, "child.json");
writeFileSync(childFile, JSON.stringify({ value: "child", child: null }));
const { stdout: childOut } = await runCli(
["put", schemaHash.trim(), childFile],
tmpStore,
);
const childHash = envValue(childOut) as string;
// Create parent node
const parentFile = join(tmpStore, "parent.json");
writeFileSync(
parentFile,
JSON.stringify({ value: "parent", child: childHash }),
);
const { stdout: parentOut } = await runCli(
["put", schemaHash.trim(), parentFile],
tmpStore,
);
const parentHash = envValue(parentOut) as string;
// Create template showing resolution (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(
templateFile,
JSON.stringify("{{ payload.value }}(res={{ resolution }})"),
);
const { stdout: tmplOut } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
const tmplHash = envValue(tmplOut) as string;
// Register template
await runCli(
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
// Render with custom decay
const { stdout: output, exitCode } = await runCli(
["render", parentHash, "--decay", "0.7"],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toContain("parent(res=1)");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.3 CLI with Template + All Parameters", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Bob" }));
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// Create template (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(
templateFile,
JSON.stringify("Greetings {{ payload.name }}!"),
);
const { stdout: tmplOut } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
const tmplHash = envValue(tmplOut) as string;
await runCli(
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
const { stdout: output, exitCode } = await runCli(
[
"render",
nodeHash,
"--resolution",
"0.8",
"--decay",
"0.6",
"--epsilon",
"0.005",
],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toBe("Greetings Bob!");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.4 CLI with Non-templated Node (YAML Fallback)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" }));
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// No template registered - should fall back to YAML
const { stdout: output, exitCode } = await runCli(
["render", nodeHash],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toContain("name:");
expect(output).toContain("Charlie");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.5 CLI Error: Invalid Decay Value", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Test" }));
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
const { exitCode, stderr } = await runCli(
["render", nodeHash, "--decay", "1.5"],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("decay");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R8: render with non-existent hash exits with error", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const { exitCode, stderr, stdout } = await runCli(
["render", "AAAAAAAAAAAAA"],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Node not found");
expect(stderr).toContain("AAAAAAAAAAAAA");
expect(stdout).toBe("");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R9: render with valid hash exits successfully", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Get @string type hash via bootstrap
const store = createFsStore(tmpStore);
const types = await bootstrap(store);
const stringType = types["@string"];
// Create and store a simple string node
const nodeFile = join(tmpStore, "test.json");
writeFileSync(nodeFile, JSON.stringify("hello world"));
const { stdout: nodeOut } = await runCli(
["put", stringType, nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// Render the valid hash
const { exitCode, stdout, stderr } = await runCli(
["render", nodeHash],
tmpStore,
);
expect(exitCode).toBe(0);
expect(stdout).toContain("hello world");
expect(stderr).toBe("");
expect(stdout).not.toContain("Error");
expect(stdout).not.toContain("Node not found");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R10: render --pipe with valid envelope succeeds", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Get @string type hash via bootstrap
const store = createFsStore(tmpStore);
const types = await bootstrap(store);
const stringType = types["@string"];
// Create envelope and pipe to render
const envelope = JSON.stringify({ type: stringType, value: "test" });
const { exitCode, stdout, stderr } = await runCliWithStdin(
["render", "--pipe"],
tmpStore,
envelope,
);
expect(exitCode).toBe(0);
expect(stdout).toContain("test");
expect(stderr).toBe("");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R11: render --pipe with invalid type hash still renders", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Use invalid type hash in envelope
const envelope = JSON.stringify({
type: "ZZZZZZZZZZZZZ",
value: "test",
});
const { exitCode, stdout, stderr } = await runCliWithStdin(
["render", "--pipe"],
tmpStore,
envelope,
);
expect(exitCode).toBe(0);
expect(stdout).toContain("test");
expect(stderr).toBe("");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
// Store validation tests removed - with auto-bootstrap in Phase 1a,
// stores are automatically created and bootstrapped when opened.
// Issue #55 validation is no longer applicable.
@@ -0,0 +1,659 @@
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";
import { envValue, putSchemaFile, runCli } from "./helpers";
// ---- Issue #50: Schema Validation in put Command ----
describe("Issue #50: Schema Validation in put", () => {
describe("Test Group 1: Valid Data (Regression Tests)", () => {
test("T1.1: Valid data matching schema should be accepted", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Create schema with required name property
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Create valid payload
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
const { stdout, stderr, exitCode } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Verify node was stored
const hash = envValue(stdout) as string;
const { exitCode: hasExitCode } = await runCli(["has", hash], tmpStore);
expect(hasExitCode).toBe(0);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T1.2: Valid data with optional properties should be accepted", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with optional property
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with only required properties
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
const { exitCode, stdout } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T1.3: Valid data with nested objects should be accepted", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with nested structure
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
address: {
type: "object",
properties: {
street: { type: "string" },
city: { type: "string" },
},
},
},
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with nested structure
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(
payloadFile,
JSON.stringify({
name: "test",
address: { street: "123 Main", city: "NYC" },
}),
);
const { exitCode, stdout } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T1.4: Valid data using @ alias for type-hash should be accepted", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify("hello world"));
const { exitCode, stdout } = await runCli(
["put", "@string", payloadFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
describe("Test Group 2: Type Mismatches (New Validation)", () => {
test("T2.1: Wrong property type should be rejected", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with name as string
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with name as number
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
const { exitCode, stdout, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).toBe(1);
expect(stdout).toBe("");
expect(stderr).toContain("Validation failed");
expect(stderr).toContain(schemaHash.trim());
expect(stderr).toContain(payloadFile);
// Verify no node was stored
const { stdout: hasOutput } = await runCli(
["has", "0000000000000"],
tmpStore,
);
expect(envValue(hasOutput)).toBe(false);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T2.2: Missing required property should be rejected", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with required name
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Empty payload
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({}));
const { exitCode, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Validation failed");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T2.3: Additional properties when disallowed should be rejected", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with additionalProperties: false
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with extra property
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(
payloadFile,
JSON.stringify({ name: "test", extra: "not allowed" }),
);
const { exitCode, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Validation failed");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T2.4: Wrong root type should be rejected", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema expecting array
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "array",
items: { type: "string" },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload is an object
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({}));
const { exitCode, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Validation failed");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T2.5: Nested type mismatch should be rejected", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with nested object
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
user: {
type: "object",
properties: {
age: { type: "number" },
},
},
},
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Payload with wrong nested type
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(
payloadFile,
JSON.stringify({ user: { age: "not a number" } }),
);
const { exitCode, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Validation failed");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
describe("Test Group 3: Schema Errors (Edge Cases)", () => {
test("T3.1: Non-existent type-hash should fail gracefully", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
const { exitCode, stderr } = await runCli(
["put", "ZZZZZZZZZZZZZ", payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Schema not found");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T3.3: Invalid @ alias should fail before validation", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({}));
const { exitCode, stderr } = await runCli(
["put", "@nonexistent", payloadFile],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Schema not found");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
describe("Test Group 4: Integration with Existing Features", () => {
test("T4.1: Hash command should not validate (dry-run consistency)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Create schema
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Invalid payload
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
// Hash command should succeed even with invalid data
const { exitCode, stdout } = await runCli(
["hash", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T4.2: Validation respects cas_ref format in schemas", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Schema with cas_ref format
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
ref: { type: "string", format: "cas_ref" },
},
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
// Valid cas_ref
const validFile = join(tmpStore, "valid.json");
writeFileSync(validFile, JSON.stringify({ ref: "0000000000000" }));
const { exitCode: validExitCode } = await runCli(
["put", schemaHash.trim(), validFile],
tmpStore,
);
expect(validExitCode).toBe(0);
// Invalid cas_ref (wrong length)
const invalidFile = join(tmpStore, "invalid.json");
writeFileSync(invalidFile, JSON.stringify({ ref: "short" }));
const { exitCode: invalidExitCode, stderr } = await runCli(
["put", schemaHash.trim(), invalidFile],
tmpStore,
);
expect(invalidExitCode).not.toBe(0);
expect(stderr).toContain("Validation failed");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T4.3: Schema self-validation still works", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Valid schema
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const hash = await putSchemaFile(tmpStore, schemaFile);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
describe("Test Group 5: Error Message Quality", () => {
test("T5.1: Error message should be helpful", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
const { stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(stderr).toContain("Validation failed");
expect(stderr).toContain(schemaHash.trim());
expect(stderr).toContain(payloadFile);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("T5.2: Error should go to stderr, not stdout", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaHash = await putSchemaFile(tmpStore, schemaFile);
const payloadFile = join(tmpStore, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
const { stdout, stderr } = await runCli(
["put", schemaHash.trim(), payloadFile],
tmpStore,
);
expect(stdout).toBe("");
expect(stderr.length).toBeGreaterThan(0);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
});
// e2e Phase 2 tests
describe("Phase 2: Schema Validation", () => {
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"put",
typeHash,
nodeFile,
],
{ stdout: "pipe", stderr: "pipe" },
);
await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
nodeHash = envValue(stdout) as string;
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
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 proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"put",
typeHash,
badFile,
],
{ 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();
expect(exitCode).not.toBe(0);
expect(stdout).toBe("");
expect(stderr).toContain("Validation failed");
expect(stderr).toContain(typeHash);
});
test("2.3 put against non-existent schema hash fails", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"put",
"AAAAAAAAAAAAA",
nodeFile,
],
{ 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).toMatchSnapshot();
});
});
@@ -0,0 +1,639 @@
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, "../src/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 envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("contentHash");
expect(envelope.value.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 envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("contentHash");
expect(envelope.value.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 envelope = JSON.parse(stdout);
expect(envelope.value).toHaveProperty("contentHash");
// Verify we can get the new version
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(JSON.parse(getOut).value).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(JSON.parse(getOut).value).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 envelope = JSON.parse(stdout);
expect(envelope.value).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(JSON.parse(getOut).value).toBe(specialContent);
});
});
describe("template get", () => {
test("retrieve template as envelope value", 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("");
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).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);
// The envelope's value preserves exact whitespace (JSON-escaped),
// so trimming the surrounding JSON output is harmless.
const content = "spaces\n\ttabs\t\nmixed";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout } = await runCli("template", "get", stringHash);
expect(JSON.parse(stdout).value).toBe(content);
});
test("support multi-line templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const multiline = "Line 1\nLine 2\nLine 3";
await runCli("template", "set", stringHash, "--inline", multiline);
const { stdout } = await runCli("template", "get", stringHash);
expect(JSON.parse(stdout).value).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 envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(Array.isArray(envelope.value)).toBe(true);
expect(envelope.value.length).toBeGreaterThanOrEqual(1);
// Check structure
const item = envelope.value[0];
expect(item).toHaveProperty("schemaHash");
expect(item).toHaveProperty("contentHash");
});
test("entry contentHash matches set result", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stdout: setOut } = await runCli(
"template",
"set",
stringHash,
"--inline",
"Some template content",
);
const { contentHash } = JSON.parse(setOut).value;
const { stdout } = await runCli("template", "list");
const value = JSON.parse(stdout).value as Array<{
schemaHash: string;
contentHash: string;
}>;
const item = value.find((i) => i.schemaHash === stringHash);
expect(item).toBeDefined();
if (item) {
expect(item.contentHash).toBe(contentHash);
}
});
test("empty list when no templates", async () => {
const { stdout, exitCode } = await runCli("template", "list");
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(Array.isArray(envelope.value)).toBe(true);
expect(envelope.value.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 envelope = JSON.parse(stdout);
// Should only contain template variables
for (const item of envelope.value) {
expect(item.schemaHash).toBeDefined();
}
});
test("output JSON envelope with array value", 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 envelope = JSON.parse(stdout);
expect(Array.isArray(envelope.value)).toBe(true);
});
});
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 envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("deleted");
expect(envelope.value.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 value = JSON.parse(stdout).value as Array<{
schemaHash: string;
contentHash: string;
}>;
// Should not find deleted template
const deleted = value.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).value;
// 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(JSON.parse(getOut).value).toBe(content);
// List
const { stdout: listOut, exitCode: listExit } = await runCli(
"template",
"list",
);
expect(listExit).toBe(0);
const listData = JSON.parse(listOut).value;
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 value = JSON.parse(stdout).value;
expect(value.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
@@ -0,0 +1,85 @@
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";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
additionalProperties: false,
}),
);
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, nodeFile]);
nodeHash = envValue(stdout) as string;
});
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 };
}
describe("Phase 1: CAS Core", () => {
test("1.9 verify returns ok for valid node", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(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();
});
});
describe("Phase 2: Schema Validation", () => {
test("2.2 verify on valid node returns ok (hash + schema)", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(envValue(stdout)).toBe("ok");
});
});
+27
View File
@@ -1,5 +1,32 @@
# @uncaged/json-cas-fs
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.6.0
## 0.5.3
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas-fs",
"version": "0.5.3",
"version": "0.6.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -16,10 +16,10 @@
],
"scripts": {
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas": "^0.6.0",
"cborg": "^4.2.3"
}
}
+1 -1
View File
@@ -1 +1 @@
export { createFsStore } from "./store.js";
export { createFsStore, openStore } from "./store.js";
+259 -9
View File
@@ -1,16 +1,24 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasNode } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
bootstrap,
computeHash,
computeSelfHash,
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 +51,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 +66,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(26);
});
});
@@ -104,7 +113,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 +261,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 +295,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 +320,241 @@ 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);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// listMeta and listSchemas (FS persistence)
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – listMeta and listSchemas", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "json-cas-fs-meta-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("C1. _index/_meta exists and contains hash after self-referencing put", async () => {
const store = createFsStore(dir);
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
const metaPath = join(dir, "_index", "_meta");
expect(existsSync(metaPath)).toBe(true);
const content = readFileSync(metaPath, "utf8");
expect(content).toBe(`${hash}\n`);
});
test("C2. multiple meta puts append, no duplicates", async () => {
const store = createFsStore(dir);
const h1 = await store[BOOTSTRAP_STORE]({ type: "object", v: 1 });
const h2 = await store[BOOTSTRAP_STORE]({ type: "object", v: 2 });
// re-put first (idempotent)
await store[BOOTSTRAP_STORE]({ type: "object", v: 1 });
const content = readFileSync(join(dir, "_index", "_meta"), "utf8");
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines).toHaveLength(2);
expect(lines).toContain(h1);
expect(lines).toContain(h2);
});
test("C3. reload from disk preserves listMeta", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "a" });
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "b" });
const store2 = createFsStore(dir);
const meta = store2.listMeta();
expect(meta).toContain(h1);
expect(meta).toContain(h2);
expect(meta).toHaveLength(2);
});
test("C4. migrates from existing nodes when _meta is missing", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "mig" });
const t = await computeSelfHash({ name: "regular" });
await store1.put(h1, { type: "string" });
// Remove _index/_meta but keep _index/* and .bin files
const metaPath = join(dir, "_index", "_meta");
rmSync(metaPath, { force: true });
expect(existsSync(metaPath)).toBe(false);
const store2 = createFsStore(dir);
expect(store2.listMeta()).toContain(h1);
expect(existsSync(metaPath)).toBe(true);
const content = readFileSync(metaPath, "utf8");
expect(content).toContain(h1);
// unrelated type hash not in meta
expect(store2.listMeta()).not.toContain(t);
});
test("C5. existing _meta is not overwritten", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "keep" });
const metaPath = join(dir, "_index", "_meta");
const before = readFileSync(metaPath, "utf8");
// Reopen and confirm content unchanged
const _store2 = createFsStore(dir);
const after = readFileSync(metaPath, "utf8");
expect(after).toBe(before);
expect(after).toContain(h1);
});
test("C6. listSchemas returns union of typeIndex[m] for m in metaSet", async () => {
const store = createFsStore(dir);
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s1 = await store.put(m, { type: "string" });
const s2 = await store.put(m, { type: "number" });
const s3 = await store.put(m, { type: "array" });
const schemas = store.listSchemas();
expect(schemas).toHaveLength(4);
expect(schemas).toContain(m);
expect(schemas).toContain(s1);
expect(schemas).toContain(s2);
expect(schemas).toContain(s3);
});
test("C7. delete persists removal from _meta", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: 1 });
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: 2 });
store1.delete(h1);
const metaPath = join(dir, "_index", "_meta");
const content = readFileSync(metaPath, "utf8");
expect(content).not.toContain(h1);
expect(content).toContain(h2);
const store2 = createFsStore(dir);
expect(store2.listMeta()).not.toContain(h1);
expect(store2.listMeta()).toContain(h2);
});
test("C8. fresh store with no self-ref puts has empty listMeta", () => {
const store = createFsStore(dir);
expect(store.listMeta()).toEqual([]);
// _meta may be absent; that's fine
const metaPath = join(dir, "_index", "_meta");
if (existsSync(metaPath)) {
const content = readFileSync(metaPath, "utf8");
expect(content).toBe("");
}
});
});
+174
View File
@@ -5,6 +5,8 @@ import {
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
@@ -12,6 +14,7 @@ import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
bootstrap,
cborEncode,
computeHash,
computeSelfHash,
@@ -19,6 +22,7 @@ import {
import { decode } from "cborg";
const INDEX_DIR = "_index";
const META_FILE = "_meta";
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
let entries: string[];
@@ -97,6 +101,61 @@ function loadOrMigrateTypeIndex(
return loadTypeIndex(indexDir);
}
function loadOrMigrateMetaSet(
dir: string,
data: Map<Hash, CasNode>,
): Set<Hash> {
const indexDir = join(dir, INDEX_DIR);
const metaPath = join(indexDir, META_FILE);
if (existsSync(metaPath)) {
try {
const content = readFileSync(metaPath, "utf8");
return new Set(parseIndexFile(content));
} catch {
return new Set();
}
}
// Migration: scan loaded nodes for self-referencing nodes (type === hash)
const metaSet = new Set<Hash>();
for (const [hash, node] of data) {
if (node.type === hash) {
metaSet.add(hash);
}
}
if (metaSet.size > 0) {
mkdirSync(indexDir, { recursive: true });
const body = `${[...metaSet].join("\n")}\n`;
writeFileSync(metaPath, body, "utf8");
}
return metaSet;
}
function appendToMetaSet(
indexDir: string,
metaSet: Set<Hash>,
hash: Hash,
): void {
if (metaSet.has(hash)) return;
metaSet.add(hash);
mkdirSync(indexDir, { recursive: true });
appendFileSync(join(indexDir, META_FILE), `${hash}\n`, "utf8");
}
function rewriteMetaSet(indexDir: string, metaSet: Set<Hash>): void {
const metaPath = join(indexDir, META_FILE);
if (metaSet.size === 0) {
try {
unlinkSync(metaPath);
} catch {
// ignore
}
return;
}
mkdirSync(indexDir, { recursive: true });
const body = `${[...metaSet].join("\n")}\n`;
writeFileSync(metaPath, body, "utf8");
}
function appendToTypeIndex(
indexDir: string,
typeIndex: Map<Hash, Hash[]>,
@@ -115,6 +174,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
loadDir(dir, data);
const indexDir = join(dir, INDEX_DIR);
const typeIndex = loadOrMigrateTypeIndex(dir, data);
const metaSet = loadOrMigrateMetaSet(dir, data);
async function putSelfReferencing(payload: unknown): Promise<Hash> {
const hash = await computeSelfHash(payload);
@@ -133,6 +193,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
appendToTypeIndex(indexDir, typeIndex, hash, hash);
}
appendToMetaSet(indexDir, metaSet, hash);
return hash;
}
@@ -175,8 +236,121 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return typeIndex.get(typeHash) ?? [];
},
listAll(): Hash[] {
return Array.from(data.keys());
},
listMeta(): Hash[] {
return Array.from(metaSet);
},
listSchemas(): Hash[] {
const result = new Set<Hash>();
for (const meta of metaSet) {
result.add(meta);
const list = typeIndex.get(meta);
if (list) {
for (const h of list) result.add(h);
}
}
return Array.from(result);
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
data.delete(hash);
// Delete file
try {
unlinkSync(join(dir, `${hash}.bin`));
} catch {
// ignore if file doesn't exist
}
// Remove from type index
const list = typeIndex.get(node.type);
if (list) {
const idx = list.indexOf(hash);
if (idx !== -1) {
list.splice(idx, 1);
}
if (list.length === 0) {
typeIndex.delete(node.type);
// Delete empty index file
try {
unlinkSync(join(indexDir, node.type));
} catch {
// ignore
}
} else {
// Rewrite index file
const body = `${list.join("\n")}\n`;
writeFileSync(join(indexDir, node.type), body, "utf8");
}
}
// Remove from meta set if applicable
if (metaSet.has(hash)) {
metaSet.delete(hash);
rewriteMetaSet(indexDir, metaSet);
}
}
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
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;
}
+22
View File
@@ -1,5 +1,27 @@
# @uncaged/json-cas
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
## 0.5.3
### Patch Changes
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas",
"version": "0.5.3",
"version": "0.6.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -16,11 +16,12 @@
],
"scripts": {
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
},
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
"liquidjs": "^10.27.0",
"xxhash-wasm": "^1.1.0"
}
}
+340
View File
@@ -0,0 +1,340 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import type { JSONSchema } from "./schema.js";
import { getSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
const OUTPUT_ALIASES = [
"@output/put",
"@output/get",
"@output/has",
"@output/hash",
"@output/verify",
"@output/refs",
"@output/walk",
"@output/list",
"@output/list-meta",
"@output/list-schema",
"@output/var-set",
"@output/var-get",
"@output/var-delete",
"@output/var-tag",
"@output/var-list",
"@output/template-set",
"@output/template-get",
"@output/template-list",
"@output/template-delete",
"@output/gc",
] as const;
// ──────────────────────────────────────────────────────────────────────────────
// Built-in Schema Registration Tests
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of 26 built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
// Should return object with 6 primitive + 20 output aliases = 26
expect(builtinSchemas).toHaveProperty("@schema");
expect(builtinSchemas).toHaveProperty("@string");
expect(builtinSchemas).toHaveProperty("@number");
expect(builtinSchemas).toHaveProperty("@object");
expect(builtinSchemas).toHaveProperty("@array");
expect(builtinSchemas).toHaveProperty("@bool");
for (const alias of OUTPUT_ALIASES) {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(26);
// 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);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// @output/* Schema Registration Tests
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - @output/* Schemas", () => {
test("each @output/* schema has a title", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
for (const alias of OUTPUT_ALIASES) {
const hash = aliases[alias];
if (!hash) throw new Error(`${alias} not found`);
const schema = getSchema(store, hash) as JSONSchema;
expect(schema).not.toBeNull();
expect(typeof schema.title).toBe("string");
expect((schema.title as string).startsWith("ucas ")).toBe(true);
}
});
test("@output/put schema describes a cas_ref string", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/put"];
if (!hash) throw new Error("@output/put not found");
const schema = getSchema(store, hash);
expect(schema).toEqual({
type: "string",
format: "cas_ref",
title: "ucas put result",
});
});
test("@output/get schema describes object with type, payload, timestamp", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/get"];
if (!hash) throw new Error("@output/get not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas get result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.type).toEqual({ type: "string", format: "cas_ref" });
expect(props.payload).toEqual({});
expect(props.timestamp).toEqual({ type: "number" });
});
test("@output/has schema describes a boolean", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/has"];
if (!hash) throw new Error("@output/has not found");
expect(getSchema(store, hash)).toEqual({
type: "boolean",
title: "ucas has result",
});
});
test("@output/verify schema describes enum of ok|corrupted|invalid", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/verify"];
if (!hash) throw new Error("@output/verify not found");
const schema = getSchema(store, hash);
expect(schema).toEqual({
type: "string",
enum: ["ok", "corrupted", "invalid"],
title: "ucas verify result",
});
});
test("@output/refs schema describes array of cas_ref strings", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/refs"];
if (!hash) throw new Error("@output/refs not found");
expect(getSchema(store, hash)).toEqual({
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas refs result",
});
});
test("@output/gc schema describes object with gc stats fields", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/gc"];
if (!hash) throw new Error("@output/gc not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas gc result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.total).toEqual({ type: "number" });
expect(props.reachable).toEqual({ type: "number" });
expect(props.collected).toEqual({ type: "number" });
expect(props.scanned).toEqual({ type: "number" });
});
test("@output/var-set schema describes a Variable object", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/var-set"];
if (!hash) throw new Error("@output/var-set not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("object");
expect(schema.title).toBe("ucas var set result");
const props = schema.properties as Record<string, JSONSchema>;
expect(props.name).toEqual({ type: "string" });
expect(props.schema).toEqual({ type: "string", format: "cas_ref" });
expect(props.value).toEqual({ type: "string", format: "cas_ref" });
});
test("@output/var-list schema describes array of Variable objects", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/var-list"];
if (!hash) throw new Error("@output/var-list not found");
const schema = getSchema(store, hash) as JSONSchema;
expect(schema.type).toBe("array");
expect(schema.title).toBe("ucas var list result");
const items = schema.items as JSONSchema;
expect(items.type).toBe("object");
const props = items.properties as Record<string, JSONSchema>;
expect(props.name).toEqual({ type: "string" });
});
test("@output/template-delete schema describes object with deleted boolean", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const hash = aliases["@output/template-delete"];
if (!hash) throw new Error("@output/template-delete not found");
expect(getSchema(store, hash)).toEqual({
type: "object",
properties: { deleted: { type: "boolean" } },
title: "ucas template delete result",
});
});
test("all @output/* schemas are distinct hashes", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const outputHashes = OUTPUT_ALIASES.map((alias) => aliases[alias]);
const uniqueHashes = new Set(outputHashes);
expect(uniqueHashes.size).toBe(OUTPUT_ALIASES.length);
});
});
describe("bootstrap - meta and schemas indexes (D1)", () => {
test("listMeta contains the bootstrap meta-schema hash", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const metaHash = aliases["@schema"];
expect(store.listMeta()).toContain(metaHash as string);
});
test("listSchemas contains meta-schema and all built-in schemas", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const schemas = store.listSchemas();
for (const [, hash] of Object.entries(aliases)) {
expect(schemas).toContain(hash);
}
expect(schemas.length).toBeGreaterThanOrEqual(6);
});
});
+248 -5
View File
@@ -60,17 +60,260 @@ const BOOTSTRAP_PAYLOAD = {
enum: { type: "array" },
const: {},
description: { type: "string" },
// P1 leaf constraints
minimum: { type: "number" },
maximum: { type: "number" },
exclusiveMinimum: { type: "number" },
exclusiveMaximum: { type: "number" },
minLength: { type: "number" },
maxLength: { type: "number" },
pattern: { type: "string" },
minItems: { type: "number" },
maxItems: { type: "number" },
uniqueItems: { type: "boolean" },
// P2 combinators + conditionals
allOf: {
type: "array",
items: { type: "object", additionalProperties: false },
},
if: { type: "object", additionalProperties: false },
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable
then: { type: "object", additionalProperties: false },
else: { type: "object", additionalProperties: false },
patternProperties: {
type: "object",
additionalProperties: { type: "object", additionalProperties: false },
},
prefixItems: {
type: "array",
items: { type: "object", additionalProperties: false },
},
// P2 leaf constraints
multipleOf: { type: "number" },
minProperties: { type: "number" },
maxProperties: { type: "number" },
default: {},
// P3 combinators
not: { type: "object", additionalProperties: false },
contains: { type: "object", additionalProperties: false },
propertyNames: { type: "object", additionalProperties: false },
// P3 metadata
examples: { type: "array" },
readOnly: { type: "boolean" },
writeOnly: { type: "boolean" },
deprecated: { type: "boolean" },
$comment: { type: "string" },
},
} as const;
const VARIABLE_PROPERTIES = {
name: { type: "string" },
schema: { type: "string", format: "cas_ref" },
value: { type: "string", format: "cas_ref" },
created: { type: "number" },
updated: { type: "number" },
tags: { type: "object" },
labels: { type: "array", items: { type: "string" } },
} as const;
const OUTPUT_SCHEMAS: ReadonlyArray<
readonly [alias: string, schema: Record<string, unknown>]
> = [
[
"@output/put",
{ type: "string", format: "cas_ref", title: "ucas put result" },
],
[
"@output/get",
{
type: "object",
properties: {
type: { type: "string", format: "cas_ref" },
payload: {},
timestamp: { type: "number" },
},
title: "ucas get result",
},
],
["@output/has", { type: "boolean", title: "ucas has result" }],
[
"@output/hash",
{ type: "string", format: "cas_ref", title: "ucas hash result" },
],
[
"@output/verify",
{
type: "string",
enum: ["ok", "corrupted", "invalid"],
title: "ucas verify result",
},
],
[
"@output/refs",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas refs result",
},
],
[
"@output/walk",
{
type: "array",
items: { type: "string" },
title: "ucas walk result",
},
],
[
"@output/list",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas list result",
},
],
[
"@output/list-meta",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas list-meta result",
},
],
[
"@output/list-schema",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas list-schema result",
},
],
[
"@output/var-set",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var set result",
},
],
[
"@output/var-get",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var get result",
},
],
[
"@output/var-delete",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var delete result",
},
],
[
"@output/var-tag",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ucas var tag result",
},
],
[
"@output/var-list",
{
type: "array",
items: { type: "object", properties: { ...VARIABLE_PROPERTIES } },
title: "ucas var list result",
},
],
[
"@output/template-set",
{
type: "object",
properties: {
schemaHash: { type: "string", format: "cas_ref" },
contentHash: { type: "string", format: "cas_ref" },
},
title: "ucas template set result",
},
],
[
"@output/template-get",
{ type: "string", title: "ucas template get result" },
],
[
"@output/template-list",
{
type: "array",
items: {
type: "object",
properties: {
schemaHash: { type: "string", format: "cas_ref" },
contentHash: { type: "string", format: "cas_ref" },
},
},
title: "ucas template list result",
},
],
[
"@output/template-delete",
{
type: "object",
properties: { deleted: { type: "boolean" } },
title: "ucas template delete result",
},
],
[
"@output/gc",
{
type: "object",
properties: {
total: { type: "number" },
reachable: { type: "number" },
collected: { type: "number" },
scanned: { type: "number" },
},
title: "ucas gc result",
},
],
];
/**
* 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, 5 primitive schemas,
* and 18 @output/* schemas (24 total).
* 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. Register @output/* schemas
const aliases: Record<string, Hash> = {
"@schema": metaHash,
"@string": stringHash,
"@number": numberHash,
"@object": objectHash,
"@array": arrayHash,
"@bool": boolHash,
};
for (const [alias, schema] of OUTPUT_SCHEMAS) {
aliases[alias] = await store.put(metaHash, schema);
}
return aliases;
}
+179
View File
@@ -0,0 +1,179 @@
import { afterEach, describe, expect, test } from "bun:test";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap } from "./bootstrap.js";
import { gc } from "./gc.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import { VariableStore } from "./variable-store.js";
const tmpDbPath = () =>
join(
tmpdir(),
`test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
);
describe("GC - Variable Model Refactoring", () => {
let store: Store;
let dbPath: string;
afterEach(() => {
try {
unlinkSync(dbPath);
} catch {
// Ignore cleanup errors
}
});
test("GC preserves variable-referenced nodes", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
const hashRef = await store.put(schemaHash, { name: "referenced" });
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("config", hashRef);
const stats = gc(store, varStore);
expect(store.has(hashRef)).toBe(true);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(1);
expect(stats.collected).toBeGreaterThanOrEqual(1);
varStore.close();
});
test("GC preserves nodes from variables with same name, different schemas", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaA = { type: "object", properties: { x: { type: "number" } } };
const schemaB = { type: "object", properties: { y: { type: "string" } } };
const schemaAHash = await putSchema(store, schemaA);
const schemaBHash = await putSchema(store, schemaB);
const hashA = await store.put(schemaAHash, { x: 42 });
const hashB = await store.put(schemaBHash, { y: "hello" });
const hashOrphan = await store.put(schemaAHash, { x: 99 });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("config", hashA);
varStore.set("config", hashB);
const stats = gc(store, varStore);
expect(store.has(hashA)).toBe(true);
expect(store.has(hashB)).toBe(true);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(2);
varStore.close();
});
test("GC removes nodes after variable deletion", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
const hashRef = await store.put(schemaHash, { name: "referenced" });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("config", hashRef);
varStore.remove("config", schemaHash);
const stats = gc(store, varStore);
expect(store.has(hashRef)).toBe(false);
expect(stats.scanned).toBe(0);
varStore.close();
});
test("GC is global across all variables", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaA = { type: "object", properties: { x: { type: "number" } } };
const schemaB = { type: "object", properties: { y: { type: "string" } } };
const schemaAHash = await putSchema(store, schemaA);
const schemaBHash = await putSchema(store, schemaB);
const hash1 = await store.put(schemaAHash, { x: 1 });
const hash2 = await store.put(schemaAHash, { x: 2 });
const hash3 = await store.put(schemaBHash, { y: "a" });
const hashOrphan = await store.put(schemaAHash, { x: 999 });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("uwf.thread", hash1);
varStore.set("uwf.workflow", hash2);
varStore.set("app.config", hash3);
const stats = gc(store, varStore);
expect(store.has(hash1)).toBe(true);
expect(store.has(hash2)).toBe(true);
expect(store.has(hash3)).toBe(true);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(3);
varStore.close();
});
test("GC integration with refactored variable store", async () => {
store = createMemoryStore();
await bootstrap(store);
const schemaA = { type: "object", properties: { x: { type: "number" } } };
const schemaB = { type: "object", properties: { y: { type: "string" } } };
const schemaAHash = await putSchema(store, schemaA);
const schemaBHash = await putSchema(store, schemaB);
const hashA1 = await store.put(schemaAHash, { x: 1 });
const hashA2 = await store.put(schemaAHash, { x: 2 });
const hashB = await store.put(schemaBHash, { y: "hello" });
const hashOrphan1 = await store.put(schemaAHash, { x: 999 });
const hashOrphan2 = await store.put(schemaBHash, { y: "orphan" });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
// Create variables
varStore.set("var1", hashA1);
varStore.set("var2", hashA2);
varStore.set("var3", hashB);
// First GC: orphans removed
let stats = gc(store, varStore);
expect(store.has(hashA1)).toBe(true);
expect(store.has(hashA2)).toBe(true);
expect(store.has(hashB)).toBe(true);
expect(store.has(hashOrphan1)).toBe(false);
expect(store.has(hashOrphan2)).toBe(false);
expect(stats.scanned).toBe(3);
// Delete one variable
varStore.remove("var2", schemaAHash);
// Second GC: hashA2 removed
stats = gc(store, varStore);
expect(store.has(hashA1)).toBe(true);
expect(store.has(hashA2)).toBe(false);
expect(store.has(hashB)).toBe(true);
expect(stats.scanned).toBe(2);
varStore.close();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { walk } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
export interface GcStats {
total: number; // Total CAS nodes before GC
reachable: number; // Nodes marked as reachable
collected: number; // Nodes deleted (swept)
scanned: number; // Variables scanned as roots
}
/**
* Garbage collection: mark-and-sweep algorithm
* - Roots: all variable values (global, not scoped)
* - Mark: recursively walk refs from roots
* - Sweep: delete unmarked nodes
* - Schema preservation: schemas of reachable nodes are also marked
*/
export function gc(store: Store, varStore: VariableStore): GcStats {
// Get all variables (no filters → global)
const variables = varStore.list();
const scanned = variables.length;
// Collect unique root hashes from all variables
const roots = new Set<Hash>();
for (const variable of variables) {
roots.add(variable.value);
}
// Mark phase: walk from all roots
const reachable = new Set<Hash>();
for (const rootHash of roots) {
walk(store, rootHash, (hash, node) => {
// Mark the node itself
reachable.add(hash);
// Mark the schema (type) of the node
reachable.add(node.type);
});
}
// Walk the schema chain to ensure bootstrap meta-schema is preserved
// For each reachable schema, walk its schema chain (not its references)
const schemasToWalk = new Set<Hash>();
for (const hash of reachable) {
const node = store.get(hash);
if (node) {
schemasToWalk.add(node.type);
}
}
for (const schemaHash of schemasToWalk) {
// Walk the schema's type chain (meta-schema, etc.)
let current: Hash | null = schemaHash;
while (current !== null && !reachable.has(current)) {
reachable.add(current);
const node = store.get(current);
if (!node || node.type === current) {
// Self-referencing or missing node, stop
break;
}
current = node.type;
}
}
// Preserve all self-referencing nodes (bootstrap meta-schema)
// These are nodes where type === hash
const allHashes = store.listAll();
for (const hash of allHashes) {
const node = store.get(hash);
if (node && node.type === hash) {
reachable.add(hash);
}
}
// Count total nodes
const total = allHashes.length;
// Sweep phase: delete unmarked nodes
let collected = 0;
for (const hash of allHashes) {
if (!reachable.has(hash)) {
store.delete(hash);
collected++;
}
}
return {
total,
reachable: reachable.size,
collected,
scanned,
};
}
+45 -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,61 @@ describe("bootstrap", () => {
);
});
test("returns a valid 13-char hash", async () => {
test("returns a map with 26 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}$/);
}
expect(Object.keys(builtinSchemas)).toHaveLength(26);
});
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 26 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(26);
});
});
+21
View File
@@ -2,7 +2,16 @@ export { bootstrap } from "./bootstrap.js";
export type { BootstrapCapableStore } from "./bootstrap-capable.js";
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
export { cborEncode } from "./cbor.js";
export { type GcStats, gc } from "./gc.js";
export { computeHash, computeSelfHash } from "./hash.js";
export { renderWithTemplate } from "./liquid-render.js";
export { registerOutputTemplates } from "./output-templates.js";
export {
type RenderOptions,
render,
renderAsync,
renderDirect,
} from "./render.js";
export type { JSONSchema } from "./schema.js";
export {
getSchema,
@@ -14,4 +23,16 @@ export {
} from "./schema.js";
export { createMemoryStore } from "./store.js";
export type { CasNode, Hash, Store } from "./types.js";
export type { Variable } from "./variable.js";
export {
CasNodeNotFoundError,
createVariableStore,
InvalidTagFormatError,
InvalidVariableNameError,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
export { verify } from "./verify.js";
export { wrapEnvelope } from "./wrap-envelope.js";
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";
}
+16
View File
@@ -27,6 +27,22 @@ export class MemStore implements BootstrapCapableStore {
return this.#inner.listByType(typeHash);
}
listAll(): Hash[] {
return this.#inner.listAll();
}
listMeta(): Hash[] {
return this.#inner.listMeta();
}
listSchemas(): Hash[] {
return this.#inner.listSchemas();
}
delete(hash: Hash): void {
this.#inner.delete(hash);
}
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash> {
return this.#inner[BOOTSTRAP_STORE](payload);
}
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap } from "./bootstrap.js";
import { registerOutputTemplates } from "./output-templates.js";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
import { createVariableStore } from "./variable-store.js";
const OUTPUT_ALIASES = [
"@output/put",
"@output/get",
"@output/has",
"@output/hash",
"@output/verify",
"@output/refs",
"@output/walk",
"@output/list",
"@output/var-set",
"@output/var-get",
"@output/var-delete",
"@output/var-tag",
"@output/var-list",
"@output/template-set",
"@output/template-get",
"@output/template-list",
"@output/template-delete",
"@output/gc",
] as const;
describe("registerOutputTemplates", () => {
let store: Store;
let varStore: VariableStore;
let tempDir: string;
afterEach(async () => {
varStore.close();
await rm(tempDir, { recursive: true });
});
test("registers a template for every @output/* schema", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const registered = await registerOutputTemplates(store, varStore);
expect(Object.keys(registered)).toHaveLength(18);
for (const alias of OUTPUT_ALIASES) {
expect(registered).toHaveProperty(alias);
}
});
test("each template is retrievable via @ucas/template/text/<hash>", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
const aliases = await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
await registerOutputTemplates(store, varStore);
const stringHash = aliases["@string"];
if (!stringHash) throw new Error("@string not found");
for (const alias of OUTPUT_ALIASES) {
const schemaHash = aliases[alias];
if (!schemaHash) throw new Error(`${alias} not found`);
const varName = `@ucas/template/text/${schemaHash}`;
const variable = varStore.get(varName, stringHash);
if (variable === null) throw new Error(`Variable ${varName} not found`);
const templateNode = store.get(variable.value);
if (templateNode === null)
throw new Error(`Template node ${variable.value} not found`);
expect(typeof templateNode.payload).toBe("string");
}
});
test("is idempotent — safe to call multiple times", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const first = await registerOutputTemplates(store, varStore);
const second = await registerOutputTemplates(store, varStore);
expect(first).toEqual(second);
});
test("@output/put template contains payload reference", async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-cas-tmpl-"));
store = createMemoryStore();
const aliases = await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
await registerOutputTemplates(store, varStore);
const putHash = aliases["@output/put"];
if (!putHash) throw new Error("@output/put not found");
const stringHash = aliases["@string"];
if (!stringHash) throw new Error("@string not found");
const variable = varStore.get(`@ucas/template/text/${putHash}`, stringHash);
if (variable === null)
throw new Error("@output/put template variable not found");
const templateNode = store.get(variable.value);
if (templateNode === null) throw new Error("Template node not found");
expect(templateNode.payload).toBe("{{ payload }}");
});
});
+87
View File
@@ -0,0 +1,87 @@
import { bootstrap } from "./bootstrap.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
const DEFAULT_TEMPLATES: ReadonlyArray<
readonly [alias: string, template: string]
> = [
["@output/put", "{{ payload }}"],
[
"@output/get",
"type: {{ payload.type }}\ntimestamp: {{ payload.timestamp }}",
],
["@output/has", "{{ payload }}"],
["@output/hash", "{{ payload }}"],
["@output/verify", "{{ payload }}"],
["@output/refs", "{% for ref in payload %}{{ ref }}\n{% endfor %}"],
["@output/walk", "{% for item in payload %}{{ item }}\n{% endfor %}"],
["@output/list", "{% for item in payload %}{{ item }}\n{% endfor %}"],
[
"@output/var-set",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-get",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-delete",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-tag",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@output/var-list",
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}",
],
[
"@output/template-set",
"schemaHash: {{ payload.schemaHash }}\ncontentHash: {{ payload.contentHash }}",
],
["@output/template-get", "{{ payload }}"],
[
"@output/template-list",
"{% for t in payload %}schemaHash: {{ t.schemaHash }}\ncontentHash: {{ t.contentHash }}\n{% endfor %}",
],
["@output/template-delete", "deleted: {{ payload.deleted }}"],
[
"@output/gc",
"total: {{ payload.total }}\nreachable: {{ payload.reachable }}\ncollected: {{ payload.collected }}\nscanned: {{ payload.scanned }}",
],
];
/**
* Register default LiquidJS templates for all @output/* schemas.
* Each template is stored as a @string CAS node and bound to
* the variable `@ucas/template/text/<schema-hash>`.
*
* Idempotent: safe to call multiple times.
*/
export async function registerOutputTemplates(
store: Store,
varStore: VariableStore,
): Promise<Record<string, Hash>> {
const aliases = await bootstrap(store);
const stringHash = aliases["@string"];
if (stringHash === undefined) {
throw new Error("@string schema not found in bootstrap result");
}
const registered: Record<string, Hash> = {};
for (const [alias, template] of DEFAULT_TEMPLATES) {
const schemaHash = aliases[alias];
if (schemaHash === undefined) {
throw new Error(`Schema alias not found: ${alias}`);
}
const contentHash = await store.put(stringHash, template);
const varName = `@ucas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
registered[alias] = contentHash;
}
return registered;
}
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");
}
+360 -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" } },
@@ -384,9 +388,111 @@ describe("bootstrap meta-schema self-reference", () => {
expect(dataNode.type).not.toBe(metaHash);
});
// ── P1 leaf constraints ──────────────────────────────────────────────────
test("accepts schema with numeric constraints (minimum/maximum)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "number",
minimum: 0,
maximum: 100,
exclusiveMinimum: -1,
exclusiveMaximum: 101,
});
expect(hash).toHaveLength(13);
// validate a conforming payload
const nodeHash = await store.put(hash, 42);
const node = store.get(nodeHash) as CasNode;
expect(validate(store, node)).toBe(true);
// validate a non-conforming payload
const badHash = await store.put(hash, 200);
const badNode = store.get(badHash) as CasNode;
expect(validate(store, badNode)).toBe(false);
});
test("accepts schema with string constraints (minLength/maxLength/pattern)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "string",
minLength: 1,
maxLength: 10,
pattern: "^[a-z]+$",
});
expect(hash).toHaveLength(13);
const goodHash = await store.put(hash, "hello");
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
const badHash = await store.put(hash, "HELLO");
expect(validate(store, store.get(badHash) as CasNode)).toBe(false);
});
test("accepts schema with array constraints (minItems/maxItems/uniqueItems)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "array",
items: { type: "number" },
minItems: 1,
maxItems: 3,
uniqueItems: true,
});
expect(hash).toHaveLength(13);
const goodHash = await store.put(hash, [1, 2, 3]);
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
const tooMany = await store.put(hash, [1, 2, 3, 4]);
expect(validate(store, store.get(tooMany) as CasNode)).toBe(false);
const dupes = await store.put(hash, [1, 1]);
expect(validate(store, store.get(dupes) as CasNode)).toBe(false);
});
test("rejects schema with wrong constraint types", async () => {
const store = createMemoryStore();
await expect(
putSchema(store, { type: "number", minimum: "zero" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { type: "string", maxLength: true } as never),
).rejects.toThrow();
await expect(
putSchema(store, { type: "array", uniqueItems: 1 } as never),
).rejects.toThrow();
});
test("accepts schema with nested property constraints", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 50 },
age: { type: "number", minimum: 0, maximum: 150 },
scores: {
type: "array",
items: { type: "number" },
minItems: 1,
uniqueItems: true,
},
},
required: ["name"],
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, {
name: "Alice",
age: 30,
scores: [95, 87],
});
expect(validate(store, store.get(good) as CasNode)).toBe(true);
});
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" });
@@ -395,4 +501,253 @@ describe("bootstrap meta-schema self-reference", () => {
const metaNode = store.get(metaHash) as CasNode;
expect(metaNode.type).toBe(metaHash);
});
// ── P2 combinators, conditionals, and leaf constraints ──────────────────
test("accepts schema with allOf", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
allOf: [
{ type: "object", properties: { name: { type: "string" } } },
{ required: ["name"] },
],
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { name: "Alice" });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, {});
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with if/then/else", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
properties: {
kind: { type: "string" },
value: {},
},
if: { properties: { kind: { const: "number" } } },
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable
then: { properties: { value: { type: "number" } } },
else: { properties: { value: { type: "string" } } },
});
expect(hash).toHaveLength(13);
});
test("accepts schema with patternProperties", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
patternProperties: {
"^x-": { type: "string" },
},
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { "x-custom": "hello" });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
});
test("accepts schema with prefixItems (tuple)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "array",
prefixItems: [{ type: "string" }, { type: "number" }],
});
expect(hash).toHaveLength(13);
});
test("accepts schema with multipleOf", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "number",
multipleOf: 5,
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, 15);
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, 7);
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with minProperties/maxProperties", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
minProperties: 1,
maxProperties: 3,
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { a: 1, b: 2 });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const empty = await store.put(hash, {});
expect(validate(store, store.get(empty) as CasNode)).toBe(false);
});
test("accepts schema with default value", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "string",
default: "hello",
});
expect(hash).toHaveLength(13);
});
test("rejects invalid P2 keyword types", async () => {
const store = createMemoryStore();
await expect(
putSchema(store, { allOf: "not-array" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { multipleOf: "five" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { patternProperties: [1, 2] } as never),
).rejects.toThrow();
});
test("collectRefs traverses allOf sub-schemas", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
allOf: [
{
type: "object",
properties: { ref: { type: "string", format: "cas_ref" } },
},
],
});
const targetHash = await store.put(innerSchema, "target");
const nodeHash = await store.put(schema, { ref: targetHash });
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
test("collectRefs traverses patternProperties", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
type: "object",
patternProperties: {
"^ref_": { type: "string", format: "cas_ref" },
},
});
const targetHash = await store.put(innerSchema, "hello");
const nodeHash = await store.put(schema, { ref_a: targetHash });
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
test("collectRefs traverses prefixItems", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
type: "array",
prefixItems: [{ type: "string", format: "cas_ref" }, { type: "number" }],
});
const targetHash = await store.put(innerSchema, "hello");
const nodeHash = await store.put(schema, [targetHash, 42]);
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
// ── P3 combinators, propertyNames, and metadata ──────────────────────────
test("accepts schema with not", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
not: { type: "string" },
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, 42);
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, "hello");
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with contains", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "array",
contains: { type: "number", minimum: 10 },
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, [1, 2, 15]);
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, [1, 2, 3]);
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with propertyNames", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
propertyNames: { pattern: "^[a-z]+$" },
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { foo: 1, bar: 2 });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, { Foo: 1 });
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with metadata keywords", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "string",
examples: ["hello", "world"],
readOnly: true,
deprecated: false,
$comment: "This is a test schema",
});
expect(hash).toHaveLength(13);
});
test("rejects invalid P3 keyword types", async () => {
const store = createMemoryStore();
await expect(
putSchema(store, { not: "not-object" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { examples: "not-array" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { readOnly: "yes" } as never),
).rejects.toThrow();
await expect(putSchema(store, { $comment: 42 } as never)).rejects.toThrow();
});
test("collectRefs traverses contains", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
type: "array",
contains: { type: "string", format: "cas_ref" },
});
const targetHash = await store.put(innerSchema, "hello");
const nodeHash = await store.put(schema, [targetHash, "not-a-ref"]);
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
});
+189 -8
View File
@@ -35,6 +35,39 @@ const ALLOWED_SCHEMA_KEYS = new Set([
"enum",
"const",
"description",
// P1 leaf constraints (no collectRefs impact)
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"minLength",
"maxLength",
"pattern",
"minItems",
"maxItems",
"uniqueItems",
// P2 combinators + conditionals (need collectRefs)
"allOf",
"if",
"then",
"else",
"patternProperties",
"prefixItems",
// P2 leaf constraints
"multipleOf",
"minProperties",
"maxProperties",
"default",
// P3 combinators (need collectRefs)
"not",
"contains",
"propertyNames",
// P3 metadata (no collectRefs impact)
"examples",
"readOnly",
"writeOnly",
"deprecated",
"$comment",
]);
const JSON_SCHEMA_TYPES = new Set([
@@ -126,6 +159,83 @@ function isValidSchema(value: unknown): boolean {
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
}
// P1 leaf constraints — type checks only
if ("minimum" in schema && typeof schema.minimum !== "number") return false;
if ("maximum" in schema && typeof schema.maximum !== "number") return false;
if (
"exclusiveMinimum" in schema &&
typeof schema.exclusiveMinimum !== "number"
)
return false;
if (
"exclusiveMaximum" in schema &&
typeof schema.exclusiveMaximum !== "number"
)
return false;
if ("minLength" in schema && typeof schema.minLength !== "number")
return false;
if ("maxLength" in schema && typeof schema.maxLength !== "number")
return false;
if ("pattern" in schema && typeof schema.pattern !== "string") return false;
if ("minItems" in schema && typeof schema.minItems !== "number") return false;
if ("maxItems" in schema && typeof schema.maxItems !== "number") return false;
if ("uniqueItems" in schema && typeof schema.uniqueItems !== "boolean")
return false;
// P2 combinators + conditionals — recursive sub-schema checks
if ("allOf" in schema) {
if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) return false;
for (const entry of schema.allOf) {
if (!isValidSchema(entry)) return false;
}
}
if ("if" in schema && !isValidSchema(schema.if)) return false;
if ("then" in schema && !isValidSchema(schema.then)) return false;
if ("else" in schema && !isValidSchema(schema.else)) return false;
if ("patternProperties" in schema) {
const pp = schema.patternProperties;
if (pp === null || typeof pp !== "object" || Array.isArray(pp))
return false;
for (const nested of Object.values(pp as Record<string, unknown>)) {
if (!isValidSchema(nested)) return false;
}
}
if ("prefixItems" in schema) {
if (!Array.isArray(schema.prefixItems) || schema.prefixItems.length === 0)
return false;
for (const entry of schema.prefixItems) {
if (!isValidSchema(entry)) return false;
}
}
// P2 leaf constraints — type checks only
if ("multipleOf" in schema && typeof schema.multipleOf !== "number")
return false;
if ("minProperties" in schema && typeof schema.minProperties !== "number")
return false;
if ("maxProperties" in schema && typeof schema.maxProperties !== "number")
return false;
// "default" accepts any value — no type check needed
// P3 combinators — recursive sub-schema checks
if ("not" in schema && !isValidSchema(schema.not)) return false;
if ("contains" in schema && !isValidSchema(schema.contains)) return false;
if ("propertyNames" in schema && !isValidSchema(schema.propertyNames))
return false;
// P3 metadata — type checks only
if ("examples" in schema && !Array.isArray(schema.examples)) return false;
if ("readOnly" in schema && typeof schema.readOnly !== "boolean")
return false;
if ("writeOnly" in schema && typeof schema.writeOnly !== "boolean")
return false;
if ("deprecated" in schema && typeof schema.deprecated !== "boolean")
return false;
if ("$comment" in schema && typeof schema.$comment !== "string") return false;
return true;
}
@@ -142,7 +252,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",
@@ -179,10 +293,11 @@ export function validate(store: Store, node: CasNode): boolean {
/**
* Recursively collect values of all properties whose schema has format: 'cas_ref'.
* Handles: direct format, anyOf (nullable refs), items (array refs),
* properties (nested objects), and additionalProperties (record refs).
* Handles: direct format, anyOf/allOf (combinators), oneOf, if/then/else (conditionals),
* not, contains, items + prefixItems (arrays), properties (nested objects),
* additionalProperties (record refs), and patternProperties (regex-keyed refs).
*/
function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
const result: Hash[] = [];
if (schema.format === "cas_ref") {
@@ -199,11 +314,55 @@ function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
return result;
}
if (schema.type === "array" && schema.items && Array.isArray(value)) {
const itemSchema = schema.items as JSONSchema;
for (const item of value as unknown[]) {
result.push(...collectRefs(itemSchema, item));
// P2: allOf — each sub-schema applies to the same value
if (Array.isArray(schema.allOf)) {
for (const sub of schema.allOf as JSONSchema[]) {
result.push(...collectRefs(sub, value));
}
}
// P2: if/then/else — conditional sub-schemas apply to the same value
if (schema.if && typeof schema.if === "object") {
result.push(...collectRefs(schema.if as JSONSchema, value));
}
if (schema.then && typeof schema.then === "object") {
result.push(...collectRefs(schema.then as JSONSchema, value));
}
if (schema.else && typeof schema.else === "object") {
result.push(...collectRefs(schema.else as JSONSchema, value));
}
if (schema.type === "array" && Array.isArray(value)) {
// P2: prefixItems — tuple validation, each item has its own schema
if (Array.isArray(schema.prefixItems)) {
const tupleSchemas = schema.prefixItems as JSONSchema[];
const arr = value as unknown[];
for (let i = 0; i < tupleSchemas.length && i < arr.length; i++) {
const ts = tupleSchemas[i];
if (ts) result.push(...collectRefs(ts, arr[i]));
}
}
if (schema.items) {
const itemSchema = schema.items as JSONSchema;
// When prefixItems exists, items applies only to remaining elements
const startIdx = Array.isArray(schema.prefixItems)
? (schema.prefixItems as unknown[]).length
: 0;
const arr = value as unknown[];
for (let i = startIdx; i < arr.length; i++) {
result.push(...collectRefs(itemSchema, arr[i]));
}
}
// P3: contains — sub-schema for array items
if (schema.contains && typeof schema.contains === "object") {
const containsSchema = schema.contains as JSONSchema;
for (const item of value as unknown[]) {
result.push(...collectRefs(containsSchema, item));
}
}
return result;
}
@@ -226,6 +385,28 @@ function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
result.push(...collectRefs(addlSchema, val));
}
}
// P2: patternProperties — regex-keyed property schemas
if (
schema.patternProperties &&
typeof schema.patternProperties === "object"
) {
const pp = schema.patternProperties as Record<string, JSONSchema>;
const obj = value as Record<string, unknown>;
for (const [pat, subSchema] of Object.entries(pp)) {
const re = new RegExp(pat);
for (const [key, val] of Object.entries(obj)) {
if (re.test(key)) {
result.push(...collectRefs(subSchema, val));
}
}
}
}
}
// P3: not — sub-schema applies to the same value
if (schema.not && typeof schema.not === "object") {
result.push(...collectRefs(schema.not as JSONSchema, value));
}
return result;
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test";
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
import { createMemoryStore } from "./store.js";
describe("createMemoryStore – meta and schema indexes", () => {
test("B1. listMeta and listSchemas are empty on a fresh store", () => {
const store = createMemoryStore();
expect(store.listMeta()).toEqual([]);
expect(store.listSchemas()).toEqual([]);
});
test("B2. self-referencing put adds hash to metaSet and listSchemas", async () => {
const store = createMemoryStore();
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
expect(store.listMeta()).toContain(hash);
expect(store.listSchemas()).toContain(hash);
});
test("B3. regular put does not add hash to metaSet", async () => {
const store = createMemoryStore();
const metaHash = await store[BOOTSTRAP_STORE]({ type: "object" });
const schemaHash = await store.put(metaHash, { type: "string" });
expect(store.listMeta()).not.toContain(schemaHash);
expect(store.listMeta()).toContain(metaHash);
});
test("B4. schema typed by meta-schema appears in listSchemas", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s = await store.put(m, { type: "string" });
const schemas = store.listSchemas();
expect(schemas).toContain(m);
expect(schemas).toContain(s);
const meta = store.listMeta();
expect(meta).toContain(m);
expect(meta).not.toContain(s);
});
test("B5. multiple meta-schemas (versioning)", async () => {
const store = createMemoryStore();
const m1 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v1" });
const m2 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v2" });
const s1 = await store.put(m1, { type: "string" });
const s2 = await store.put(m2, { type: "number" });
const meta = store.listMeta();
expect(meta).toContain(m1);
expect(meta).toContain(m2);
expect(meta).toHaveLength(2);
const schemas = store.listSchemas();
expect(schemas).toContain(m1);
expect(schemas).toContain(m2);
expect(schemas).toContain(s1);
expect(schemas).toContain(s2);
});
test("B6. idempotent self-referencing put does not duplicate", async () => {
const store = createMemoryStore();
const payload = { type: "object", title: "dup" };
const h1 = await store[BOOTSTRAP_STORE](payload);
const h2 = await store[BOOTSTRAP_STORE](payload);
expect(h1).toBe(h2);
const meta = store.listMeta();
const occurrences = meta.filter((h) => h === h1).length;
expect(occurrences).toBe(1);
});
test("B7. delete removes hash from metaSet and listSchemas", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s = await store.put(m, { type: "string" });
expect(store.listMeta()).toContain(m);
expect(store.listSchemas()).toContain(s);
store.delete(m);
expect(store.listMeta()).not.toContain(m);
// schemas typed by deleted meta no longer surface
expect(store.listSchemas()).not.toContain(s);
expect(store.listSchemas()).not.toContain(m);
});
});
+38
View File
@@ -8,6 +8,7 @@ import type { CasNode, Hash } from "./types.js";
export function createMemoryStore(): BootstrapCapableStore {
const data = new Map<Hash, CasNode>();
const byType = new Map<Hash, Set<Hash>>();
const metaSet = new Set<Hash>();
function indexHash(type: Hash, hash: Hash): void {
let set = byType.get(type);
@@ -24,6 +25,7 @@ export function createMemoryStore(): BootstrapCapableStore {
data.set(hash, { type: hash, payload, timestamp: Date.now() });
indexHash(hash, hash);
}
metaSet.add(hash);
return hash;
}
@@ -52,6 +54,42 @@ export function createMemoryStore(): BootstrapCapableStore {
return set ? [...set] : [];
},
listAll(): Hash[] {
return Array.from(data.keys());
},
listMeta(): Hash[] {
return Array.from(metaSet);
},
listSchemas(): Hash[] {
const result = new Set<Hash>();
for (const meta of metaSet) {
result.add(meta);
const set = byType.get(meta);
if (set) {
for (const h of set) result.add(h);
}
}
return Array.from(result);
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
data.delete(hash);
// Remove from type index
const set = byType.get(node.type);
if (set) {
set.delete(hash);
if (set.size === 0) {
byType.delete(node.type);
}
}
metaSet.delete(hash);
}
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
+4
View File
@@ -24,4 +24,8 @@ export type Store = {
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
listByType(typeHash: Hash): Hash[];
listAll(): Hash[];
listMeta(): Hash[];
listSchemas(): Hash[];
delete(hash: Hash): void;
};
File diff suppressed because it is too large Load Diff
+726
View File
@@ -0,0 +1,726 @@
import { Database } from "bun:sqlite";
import type { Hash, Store } from "./types.js";
import type { Variable } from "./variable.js";
/**
* Custom error types for variable operations
*/
export class VariableNotFoundError extends Error {
constructor(
public variableName: string,
public variableSchema: Hash,
) {
super(`Variable not found: name=${variableName}, schema=${variableSchema}`);
this.name = "VariableNotFoundError";
}
}
export class InvalidVariableNameError extends Error {
constructor(
public variableName: string,
public reason: string,
) {
super(`Invalid variable name "${variableName}": ${reason}`);
this.name = "InvalidVariableNameError";
}
}
export class SchemaMismatchError extends Error {
constructor(
public expected: string,
public actual: string,
) {
super(`Schema mismatch: expected ${expected}, got ${actual}`);
this.name = "SchemaMismatchError";
}
}
export class CasNodeNotFoundError extends Error {
constructor(
public readonly hash: string,
message?: string,
) {
super(message ?? `CAS node not found: ${hash}`);
this.name = "CasNodeNotFoundError";
}
}
export class TagLabelConflictError extends Error {
constructor(
public conflictName: string,
public existingType: "tag" | "label",
public attemptedType: "tag" | "label",
) {
super(`Conflict: '${conflictName}' already exists as a ${existingType}`);
this.name = "TagLabelConflictError";
}
}
export class InvalidTagFormatError extends Error {
constructor(tag: string) {
super(`Invalid tag format: ${tag}`);
this.name = "InvalidTagFormatError";
}
}
/**
* Variable store with SQLite backend
*/
export class VariableStore {
private db: Database;
constructor(
dbPath: string,
private casStore: Store,
) {
this.db = new Database(dbPath, { create: true });
// Enable foreign keys
this.db.exec("PRAGMA foreign_keys = ON");
this.initDb();
}
private initDb(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS variables (
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
PRIMARY KEY (name, schema)
);
CREATE INDEX IF NOT EXISTS idx_var_name ON variables(name);
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
CREATE TABLE IF NOT EXISTS variable_tags (
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (variable_name, variable_schema, key),
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS variable_labels (
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (variable_name, variable_schema, name),
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key);
CREATE INDEX IF NOT EXISTS idx_var_tag_key_value ON variable_tags(key, value);
CREATE INDEX IF NOT EXISTS idx_var_label_name ON variable_labels(name);
`);
}
/**
* Validate variable name format
* @ is allowed at the start of the first segment (system-reserved)
*/
private validateName(name: string): void {
// Rule 1: Cannot be empty
if (name === "") {
throw new InvalidVariableNameError(name, "Name cannot be empty");
}
// Rule 2: No leading slash
if (name.startsWith("/")) {
throw new InvalidVariableNameError(
name,
"Name cannot start with leading slash",
);
}
// Rule 3: No trailing slash
if (name.endsWith("/")) {
throw new InvalidVariableNameError(
name,
"Name cannot end with trailing slash",
);
}
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment)
const segments = name.split("/");
for (let i = 0; i < segments.length; i++) {
const segment = segments[i] as string;
if (segment === "") {
throw new InvalidVariableNameError(
name,
"Name contains empty segment (consecutive slashes //)",
);
}
// Check for invalid characters
// First segment can start with @, all segments can contain [a-zA-Z0-9._-]
const regex = i === 0 ? /^@?[a-zA-Z0-9._-]+$/ : /^[a-zA-Z0-9._-]+$/;
if (!regex.test(segment)) {
throw new InvalidVariableNameError(
name,
`Segment "${segment}" contains invalid characters (only ${i === 0 ? "@, " : ""}a-z, A-Z, 0-9, ., _, - allowed)`,
);
}
}
}
/**
* Extract schema hash from CAS node
*/
private extractSchema(hash: string): string {
const node = this.casStore.get(hash);
if (node === null) {
throw new CasNodeNotFoundError(hash);
}
return node.type;
}
/**
* Load tags for a variable
*/
private loadTags(name: string, schema: Hash): Record<string, string> {
const stmt = this.db.prepare(`
SELECT key, value
FROM variable_tags
WHERE variable_name = ? AND variable_schema = ?
`);
const rows = stmt.all(name, schema) as Array<{
key: string;
value: string;
}>;
const tags: Record<string, string> = {};
for (const row of rows) {
tags[row.key] = row.value;
}
return tags;
}
/**
* Load labels for a variable
*/
private loadLabels(name: string, schema: Hash): string[] {
const stmt = this.db.prepare(`
SELECT name
FROM variable_labels
WHERE variable_name = ? AND variable_schema = ?
ORDER BY name ASC
`);
const rows = stmt.all(name, schema) as Array<{ name: string }>;
return rows.map((row) => row.name);
}
/**
* Set a variable (upsert: create or update)
*/
set(
name: string,
value: string,
options?: {
tags?: Record<string, string>;
labels?: string[];
},
): Variable {
// Validate name format
this.validateName(name);
const schema = this.extractSchema(value);
// Check if variable exists
const existing = this.get(name, schema);
if (existing !== null) {
// Update existing variable
const now = Date.now();
// If options provided, use them; otherwise preserve existing
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
// Check for tag/label conflicts when updating with new options
if (options !== undefined) {
const tagKeys = Object.keys(tags);
for (const key of tagKeys) {
if (labels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
}
this.db.exec("BEGIN TRANSACTION");
try {
// Update value and timestamp
const updateStmt = this.db.prepare(`
UPDATE variables
SET value = ?, updated = ?
WHERE name = ? AND schema = ?
`);
updateStmt.run(value, now, name, schema);
// If options provided, update tags/labels
if (options !== undefined) {
// Delete existing tags and labels
this.db
.prepare(`
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ?
`)
.run(name, schema);
this.db
.prepare(`
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ?
`)
.run(name, schema);
// Insert new tags
const tagKeys = Object.keys(tags);
if (tagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, val] of Object.entries(tags)) {
tagStmt.run(name, schema, key, val);
}
}
// Insert new labels
if (labels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const labelName of labels) {
labelStmt.run(name, schema, labelName);
}
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
return {
name,
schema,
value,
created: existing.created,
updated: now,
tags,
labels: [...labels],
};
}
// Create new variable
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
// Check for tag/label conflicts
const tagKeys = Object.keys(tags);
for (const key of tagKeys) {
if (labels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
const now = Date.now();
this.db.exec("BEGIN TRANSACTION");
try {
const stmt = this.db.prepare(`
INSERT INTO variables (name, schema, value, created, updated)
VALUES (?, ?, ?, ?, ?)
`);
stmt.run(name, schema, value, now, now);
// Insert tags
if (tagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, val] of Object.entries(tags)) {
tagStmt.run(name, schema, key, val);
}
}
// Insert labels
if (labels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const labelName of labels) {
labelStmt.run(name, schema, labelName);
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
return {
name,
schema,
value,
created: now,
updated: now,
tags,
labels: [...labels],
};
}
/**
* Get a variable by name, optionally with schema
*/
/**
* Get a variable by name and schema
* @param name - Variable name
* @param schema - Schema hash (required)
* @returns Variable if found, null otherwise
*/
get(name: string, schema: Hash): Variable | null {
// Precise match with schema
const stmt = this.db.prepare(`
SELECT name, schema, value, created, updated
FROM variables
WHERE name = ? AND schema = ?
`);
const row = stmt.get(name, schema) as
| {
name: string;
schema: string;
value: string;
created: number;
updated: number;
}
| undefined
| null;
if (row === undefined || row === null) {
return null;
}
const tags = this.loadTags(row.name, row.schema);
const labels = this.loadLabels(row.name, row.schema);
return {
name: row.name,
schema: row.schema,
value: row.value,
created: row.created,
updated: row.updated,
tags,
labels,
};
}
/**
* Update a variable's value (with schema validation)
*/
update(name: string, schema: Hash, value: string): Variable {
// Validate name format
this.validateName(name);
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(name, schema);
}
const newSchema = this.extractSchema(value);
if (newSchema !== existing.schema) {
throw new SchemaMismatchError(existing.schema, newSchema);
}
const now = Date.now();
const stmt = this.db.prepare(`
UPDATE variables
SET value = ?, updated = ?
WHERE name = ? AND schema = ?
`);
stmt.run(value, now, name, schema);
return {
...existing,
value,
updated: now,
};
}
/**
* Remove a variable (or all variants if schema omitted)
*/
remove(name: string): Variable[];
remove(name: string, schema: Hash): Variable;
remove(name: string, schema?: Hash): Variable | Variable[] {
if (schema !== undefined) {
// Remove specific (name, schema) variant
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(name, schema);
}
const stmt = this.db.prepare(`
DELETE FROM variables WHERE name = ? AND schema = ?
`);
stmt.run(name, schema);
return existing;
}
// Remove all schema variants for this name
const variants = this.list({ exactName: name });
if (variants.length === 0) {
return [];
}
const stmt = this.db.prepare(`
DELETE FROM variables WHERE name = ?
`);
stmt.run(name);
return variants;
}
/**
* List variables with optional filters
*/
list(options?: {
namePrefix?: string;
exactName?: string;
schema?: Hash;
tags?: Record<string, string>;
labels?: string[];
}): Variable[] {
// Validate mutually exclusive options
if (options?.namePrefix !== undefined && options?.exactName !== undefined) {
throw new Error(
"namePrefix and exactName are mutually exclusive - cannot specify both",
);
}
const namePrefix = options?.namePrefix ?? "";
const exactName = options?.exactName;
const schema = options?.schema;
const filterTags = options?.tags ?? {};
const filterLabels = options?.labels ?? [];
// Build query with filters
let query = `
SELECT DISTINCT v.name, v.schema, v.value, v.created, v.updated
FROM variables v
`;
const params: (string | number)[] = [];
// Tag filters (AND logic)
const tagKeys = Object.keys(filterTags);
for (let i = 0; i < tagKeys.length; i++) {
const key = tagKeys[i] as string;
const value = filterTags[key] as string;
query += `
INNER JOIN variable_tags t${i} ON v.name = t${i}.variable_name
AND v.schema = t${i}.variable_schema
AND t${i}.key = ? AND t${i}.value = ?
`;
params.push(key, value);
}
// Label filters (AND logic)
for (let i = 0; i < filterLabels.length; i++) {
const label = filterLabels[i] as string;
query += `
INNER JOIN variable_labels l${i} ON v.name = l${i}.variable_name
AND v.schema = l${i}.variable_schema
AND l${i}.name = ?
`;
params.push(label);
}
// WHERE clause for name filters and schema
const whereClauses: string[] = [];
if (exactName !== undefined) {
whereClauses.push("v.name = ?");
params.push(exactName);
} else if (namePrefix !== "") {
whereClauses.push("v.name LIKE ? || '%'");
params.push(namePrefix);
}
if (schema !== undefined) {
whereClauses.push("v.schema = ?");
params.push(schema);
}
if (whereClauses.length > 0) {
query += ` WHERE ${whereClauses.join(" AND ")}`;
}
query += " ORDER BY v.created ASC";
const stmt = this.db.prepare(query);
const rows = stmt.all(...params) as Array<{
name: string;
schema: string;
value: string;
created: number;
updated: number;
}>;
return rows.map((row) => ({
name: row.name,
schema: row.schema,
value: row.value,
created: row.created,
updated: row.updated,
tags: this.loadTags(row.name, row.schema),
labels: this.loadLabels(row.name, row.schema),
}));
}
/**
* Add/update/delete tags and labels
*/
tag(
name: string,
schema: Hash,
operations: {
add?: Record<string, string>; // tags to add/update
addLabels?: string[]; // labels to add
delete?: string[]; // tag keys or label names to delete
},
): Variable {
// Validate name format
this.validateName(name);
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(name, schema);
}
const addTags = operations.add ?? {};
const addLabels = operations.addLabels ?? [];
const deleteNames = operations.delete ?? [];
// Check for conflicts between tags and labels
const newTagKeys = Object.keys(addTags);
for (const key of newTagKeys) {
// Check if this key is being added as a label in the same operation
if (addLabels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
// Check if this key already exists as a label (and not being deleted)
if (existing.labels.includes(key) && !deleteNames.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
for (const labelName of addLabels) {
// Check if this name is being added as a tag in the same operation
if (newTagKeys.includes(labelName)) {
throw new TagLabelConflictError(labelName, "tag", "label");
}
// Check if this name already exists as a tag key (and not being deleted)
if (
existing.tags[labelName] !== undefined &&
!deleteNames.includes(labelName)
) {
throw new TagLabelConflictError(labelName, "tag", "label");
}
}
const now = Date.now();
this.db.exec("BEGIN TRANSACTION");
try {
// Update timestamp
const updateStmt = this.db.prepare(`
UPDATE variables SET updated = ? WHERE name = ? AND schema = ?
`);
updateStmt.run(now, name, schema);
// Delete tags and labels
if (deleteNames.length > 0) {
const deleteTagStmt = this.db.prepare(`
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ? AND key = ?
`);
const deleteLabelStmt = this.db.prepare(`
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ? AND name = ?
`);
for (const deleteName of deleteNames) {
deleteTagStmt.run(name, schema, deleteName);
deleteLabelStmt.run(name, schema, deleteName);
}
}
// Add or update tags
if (newTagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT OR REPLACE INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, value] of Object.entries(addTags)) {
tagStmt.run(name, schema, key, value);
}
}
// Add labels (with conflict handling)
if (addLabels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT OR IGNORE INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const labelName of addLabels) {
labelStmt.run(name, schema, labelName);
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
// Return updated variable
const updated = this.get(name, schema);
if (updated === null) {
throw new VariableNotFoundError(name, schema);
}
return updated;
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
}
/**
* Create a variable store
*/
export function createVariableStore(
dbPath: string,
casStore: Store,
): VariableStore {
return new VariableStore(dbPath, casStore);
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import type { Variable } from "./variable.js";
describe("Variable Type", () => {
test("Variable type uses (name, schema) composite key", () => {
const variable: Variable = {
name: "config",
schema: "ABC123DEF4567",
value: "XYZ789GHI0123",
created: 1234567890000,
updated: 1234567890000,
tags: { env: "prod" },
labels: ["critical"],
};
expect(variable.name).toBe("config");
expect(variable.schema).toBe("ABC123DEF4567");
// id and scope should not exist
expect((variable as unknown as { id?: unknown }).id).toBeUndefined();
expect((variable as unknown as { scope?: unknown }).scope).toBeUndefined();
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { Hash } from "./types.js";
/**
* Variable: mutable binding to an immutable CAS node
* Identified by composite key (name, schema)
*/
export type Variable = {
name: string; // variable name (unique per schema)
schema: Hash; // schema hash (part of composite key)
value: Hash; // CAS node hash
created: number; // epoch ms
updated: number; // epoch ms
tags: Record<string, string>; // key-value pairs
labels: string[]; // bare identifiers
};
@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { createMemoryStore } from "./store.js";
import { wrapEnvelope } from "./wrap-envelope.js";
describe("wrapEnvelope", () => {
test("resolves @output/put alias and returns envelope", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@output/put", "AAAAAAAAAAAAA");
expect(envelope.type).toBe(aliases["@output/put"]);
expect(envelope.value).toBe("AAAAAAAAAAAAA");
});
test("resolves @output/has alias with boolean value", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@output/has", true);
expect(envelope.type).toBe(aliases["@output/has"]);
expect(envelope.value).toBe(true);
});
test("resolves @output/gc alias with object value", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const gcStats = { total: 100, reachable: 80, collected: 20, scanned: 5 };
const envelope = await wrapEnvelope(store, "@output/gc", gcStats);
expect(envelope.type).toBe(aliases["@output/gc"]);
expect(envelope.value).toEqual(gcStats);
});
test("resolves primitive alias @string", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const envelope = await wrapEnvelope(store, "@string", "hello");
expect(envelope.type).toBe(aliases["@string"]);
expect(envelope.value).toBe("hello");
});
test("throws for unknown alias", async () => {
const store = createMemoryStore();
await bootstrap(store);
await expect(
wrapEnvelope(store, "@output/nonexistent", "value"),
).rejects.toThrow("Unknown schema alias: @output/nonexistent");
});
test("is idempotent — same alias returns same type hash", async () => {
const store = createMemoryStore();
const first = await wrapEnvelope(store, "@output/verify", "ok");
const second = await wrapEnvelope(store, "@output/verify", "corrupted");
expect(first.type).toBe(second.type);
expect(first.value).toBe("ok");
expect(second.value).toBe("corrupted");
});
test("preserves complex object values without mutation", async () => {
const store = createMemoryStore();
await bootstrap(store);
const original = {
name: "test",
schema: "AAAAAAAAAAAAA",
value: "BBBBBBBBBBBBB",
created: 1000,
updated: 2000,
tags: { env: "prod" },
labels: ["stable"],
};
const envelope = await wrapEnvelope(store, "@output/var-set", original);
expect(envelope.value).toEqual(original);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { bootstrap } from "./bootstrap.js";
import type { Hash, Store } from "./types.js";
/**
* Resolve a schema alias (e.g. "@output/put") to its hash via bootstrap,
* then return a typed envelope ready for store.put() or direct rendering.
*/
export async function wrapEnvelope(
store: Store,
schemaAlias: string,
value: unknown,
): Promise<{ type: Hash; value: unknown }> {
const aliases = await bootstrap(store);
const typeHash = aliases[schemaAlias];
if (typeHash === undefined) {
throw new Error(`Unknown schema alias: ${schemaAlias}`);
}
return { type: typeHash, value };
}
@@ -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();
@@ -68,13 +72,34 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
expect(properties).not.toHaveProperty("$ref");
expect(properties).not.toHaveProperty("$id");
expect(properties).not.toHaveProperty("$defs");
expect(properties).not.toHaveProperty("allOf");
expect(properties).not.toHaveProperty("not");
// P2 keywords should be present
expect(properties).toHaveProperty("allOf");
expect(properties).toHaveProperty("if");
expect(properties).toHaveProperty("then");
expect(properties).toHaveProperty("else");
expect(properties).toHaveProperty("patternProperties");
expect(properties).toHaveProperty("prefixItems");
expect(properties).toHaveProperty("multipleOf");
expect(properties).toHaveProperty("minProperties");
expect(properties).toHaveProperty("maxProperties");
expect(properties).toHaveProperty("default");
// P3 keywords should be present
expect(properties).toHaveProperty("not");
expect(properties).toHaveProperty("contains");
expect(properties).toHaveProperty("propertyNames");
expect(properties).toHaveProperty("examples");
expect(properties).toHaveProperty("readOnly");
expect(properties).toHaveProperty("writeOnly");
expect(properties).toHaveProperty("deprecated");
expect(properties).toHaveProperty("$comment");
});
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 +468,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 +611,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();
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Prevent publishing packages that still reference workspace:* dependencies
if grep -q '"workspace:' package.json 2>/dev/null; then
echo "❌ Found workspace:* dependencies in package.json — cannot publish directly."
echo " Use 'changeset publish' which resolves workspace protocol automatically."
grep '"workspace:' package.json
exit 1
fi