Compare commits

..

22 Commits

Author SHA1 Message Date
xiaoju ac87d2ccb3 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:19:42 +00:00
xiaoju fddbb1549e feat: RFC-31 Phase 3 — CLI var 子命令重写
- var set <name> <hash> (upsert, replaces create+update)
- var get <name> --schema <hash> (schema required)
- var delete <name> [--schema <hash>] (optional schema)
- var list [prefix] (replaces --scope)
- var tag <name> --schema <hash> ...
- 41 CLI tests, all passing

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

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

Fixes #34

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

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

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

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

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

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

## Breaking Changes

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

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

## New Features

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

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

## Implementation Details

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

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

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

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

## Changes

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

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

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

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

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

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

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

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

Key changes:

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

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

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

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

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

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

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

Fixes #32

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

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

Implements: #23

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

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

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

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

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

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

Closes #22

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 06:42:00 +00:00
xiaonuo 76dab6737c Merge pull request 'feat: RFC-20 Phase 1 - Variable CRUD Operations' (#25) from fix/21-variable-crud into main 2026-05-30 06:22:44 +00:00
xingyue 1e8ccb8962 feat: add ucas command alias to cli-json-cas bin field
Fixes #24

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-05-30 14:06:23 +08:00
xiaoju cf716c5115 feat: implement RFC-20 Phase 1 variable CRUD operations
Add a complete variable system for json-cas that provides mutable named
bindings to immutable CAS nodes.

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

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

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

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

Fixes #21

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-29 01:55:12 +00:00
20 changed files with 5636 additions and 20 deletions
+220
View File
@@ -0,0 +1,220 @@
name: "retrospect-workflow"
description: "Post-execution retrospective: analyze a completed thread, find inefficiencies, and improve the workflow definition."
roles:
analyst:
description: "Scans thread execution for anomalies and produces a findings report"
goal: "You are a workflow execution analyst. You review completed thread data to find inefficiencies, wasted effort, and procedure gaps."
capabilities:
- data-analysis
procedure: |
You receive a completed thread ID in your task prompt.
Phase 0 — Validation (must pass before any analysis):
1. Run `uwf step list <thread-id>` to get thread metadata including the workflow hash
2. Run `uwf workflow show <workflow-hash>` to get the workflow name
3. Verify the workflow exists locally: check `.workflows/<name>.yaml` in the current repo
- If NOT found: output $status=wrong_project with the workflow name. Do NOT proceed.
4. Compare the thread's workflow hash against the current registered version:
- Run `uwf workflow show <name>` to get the current hash
- If hashes differ: the thread ran on an older version. Note this — you will need to diff versions after analysis.
Phase 1 — Overview scan:
5. From the step list, compute a health signal for each step:
- Duration: flag if >2x the median of other steps
- Output tokens: flag if >2x the median
- Status flow: flag non-happy-path transitions (rejected, fix_code, fix_spec, hook_failed)
- Step count: flag if the same role appears more than expected (indicates loops)
6. If no anomalies found AND versions match: output $status=clean
7. If no anomalies found BUT versions differ:
- Diff the two workflow versions to check if any procedure changes are relevant
- If the current version already addresses potential concerns: output $status=clean with a note
- Otherwise: proceed to Phase 2
Phase 2 — Targeted deep-dive (only for flagged steps):
8. For each flagged step, run `uwf step show <hash>` to get the detail with turns
9. Analyze the turn sequence for:
- Repeated tool calls with the same or similar input (blind retries)
- Tool errors followed by no strategy change (same approach retried)
- Unnecessary exploration (reading files or running commands unrelated to the task)
- Hallucinated commands or flags (commands that don't exist or wrong syntax)
- Excessive turns before reaching the goal
10. For each finding, record:
- Which role and step hash
- What happened (specific turn indices and commands)
- Root cause hypothesis (procedure gap, missing pitfall, unclear instruction)
- Suggested fix (what to add/change in the procedure)
11. If versions differ: compare findings against the version diff.
Mark any finding that is already fixed in the current version as "resolved_in_current".
Only report findings that are NOT yet addressed.
Output a structured findings report. Set $status=clean if nothing actionable, $status=findings if unresolved issues exist, or $status=wrong_project if the workflow doesn't belong here.
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
frontmatter:
oneOf:
- properties:
$status: { const: "clean" }
summary: { type: string }
required: [$status, summary]
- properties:
$status: { const: "findings" }
report: { type: string }
targetWorkflow: { type: string }
required: [$status, report, targetWorkflow]
- properties:
$status: { const: "wrong_project" }
workflowName: { type: string }
required: [$status, workflowName]
proposer:
description: "Translates findings into concrete workflow edits"
goal: "You are a workflow improvement proposer. You read the analyst's findings and produce specific, minimal edits to the workflow YAML."
capabilities:
- planning
procedure: |
1. Read the analyst's findings report from your task prompt
2. Locate the target workflow YAML:
- Workflow definitions live in the WORKFLOW ENGINE repo (where `uwf` is developed), NOT in the repo that was analyzed.
- Find it via: `uwf workflow show <targetWorkflow> --format yaml` to read the current definition
- The physical file is `.workflows/<targetWorkflow>.yaml` in the workflow engine repo
- Use `git rev-parse --show-toplevel` in the current directory to find the workflow engine repo root
3. Read the current workflow YAML to understand existing procedures
4. For each finding, draft a minimal edit:
- Prefer adding a pitfall note or clarifying instruction over restructuring
- If a procedure step is ambiguous, make it explicit
- If a tool usage pattern is wrong, add a "Do NOT" or "IMPORTANT" note
- Keep edits surgical — don't rewrite procedures that work fine
5. Check if existing tests need updating (search for test files referencing the workflow)
6. Produce a change plan as CAS text node via `uwf cas put-text "<plan>"`
The plan should list each edit with:
- File path
- What to change (old text → new text, or addition)
- Why (linked to which finding)
- Any test updates needed
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
frontmatter:
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "no_action" }
reason: { type: string }
required: [$status, reason]
developer:
description: "Applies the proposed workflow edits"
goal: "You are a developer agent. You apply workflow YAML edits and update related tests."
capabilities:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The workflow definitions live in THIS repo (the workflow engine), not the repo that was analyzed.
Before starting any work, set up an isolated worktree:
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
2. `git fetch origin` to get latest refs
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
4. `cd .worktrees/retrospect/<short-slug> && bun install`
5. ALL subsequent work must happen inside the worktree directory.
Then apply changes:
6. Read the change plan from CAS: `uwf cas get <plan hash>`
7. Apply each edit from the plan to the workflow YAML
8. Update or add tests as specified in the plan
9. Run `bun run build` and `bun test` to verify
10. Run `bun run check` for lint
11. Commit with message: `improve: <workflow-name> — <brief summary>`
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Reviews the workflow edits for correctness"
goal: "You are a reviewer. You verify that workflow edits are minimal, correct, and actually address the findings."
capabilities:
- code-review
procedure: |
The worktree path is provided in your task prompt. cd into it first.
Review criteria:
1. Each edit must trace back to a specific finding — no drive-by changes
2. Edits should be minimal — don't rewrite working procedures
3. New pitfall notes or instructions must be clear and actionable
4. Tests must be updated if assertions changed
5. `bun run build` and `bun test` must pass
6. `bunx biome check` must pass
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
required: [$status, comments, worktree]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR."
capabilities: []
procedure: |
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message: `git commit -m "improve: <workflow> — <summary>"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
- IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files. cd to the repo root first.
- Do NOT pass `--repo` — let tea auto-detect from the main repo's git remote.
- PR description must include: What / Why / Findings / Changes sections
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
analyst:
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
wrong_project: { role: "$END", prompt: "Thread uses workflow '{{{workflowName}}}' which does not exist in this project. Run retrospect from the correct repo." }
proposer:
no_action: { role: "$END", prompt: "No actionable changes needed: {{{reason}}}." }
ready: { role: "developer", prompt: "Apply the change plan (CAS hash: {{{plan}}}) to the workflow definitions in this repo." }
developer:
done: { role: "reviewer", prompt: "Review workflow edits on branch {{{branch}}} at {{{worktree}}}." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in {{{worktree}}}." }
approved: { role: "committer", prompt: "Approved. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow improved." }
+8 -12
View File
@@ -10,11 +10,12 @@
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0",
"ulidx": "^2.4.1",
},
},
"packages/cli-json-cas": {
"name": "@uncaged/cli-json-cas",
"version": "0.5.0",
"version": "0.5.3",
"bin": {
"json-cas": "./src/index.ts",
},
@@ -25,7 +26,7 @@
},
"packages/json-cas": {
"name": "@uncaged/json-cas",
"version": "0.5.0",
"version": "0.5.3",
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
@@ -34,19 +35,12 @@
},
"packages/json-cas-fs": {
"name": "@uncaged/json-cas-fs",
"version": "0.5.0",
"version": "0.5.3",
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"cborg": "^4.2.3",
},
},
"packages/json-cas-workflow": {
"name": "@uncaged/json-cas-workflow",
"version": "0.5.0",
"dependencies": {
"@uncaged/json-cas": "workspace:^",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
@@ -127,8 +121,6 @@
"@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@workspace:packages/json-cas-fs"],
"@uncaged/json-cas-workflow": ["@uncaged/json-cas-workflow@workspace:packages/json-cas-workflow"],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
@@ -209,6 +201,8 @@
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
@@ -291,6 +285,8 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ulidx": ["ulidx@2.4.1", "", { "dependencies": { "layerr": "^3.0.0" } }, "sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+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",
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "0.5.3",
"type": "module",
"bin": {
"json-cas": "./src/index.ts"
"json-cas": "./src/index.ts",
"ucas": "./src/index.ts"
},
"scripts": {
"test": "bun test",
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const pkgPath = resolve(import.meta.dir, "../package.json");
const entrypoint = resolve(import.meta.dir, "index.ts");
async function runCli(
args: string[],
storePath?: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const finalArgs = storePath
? ["bun", entrypoint, "--store", storePath, ...args]
: ["bun", entrypoint, ...args];
const proc = Bun.spawn(finalArgs, {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { stdout, stderr, exitCode };
}
describe("ucas command alias", () => {
test("T1: ucas bin entry exists in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe("./src/index.ts");
});
test("T2: json-cas bin entry is preserved in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin["json-cas"]).toBe("./src/index.ts");
});
test("T3: ucas command is executable and shows help", async () => {
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
test("T4: both commands point to the same entrypoint", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
});
});
describe("ucas render command", () => {
test("R1: render requires hash argument", async () => {
const { exitCode, stderr } = await runCli(["render"]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Usage");
});
test("R2: render with missing hash shows error", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const { exitCode, stdout } = await runCli(
["render", "ZZZZZZZZZZZZZ"],
tmpStore,
);
// Missing hash renders as cas: reference
expect(exitCode).toBe(0);
expect(stdout).toContain("cas:ZZZZZZZZZZZZZ");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("R3: render with invalid numeric flag fails", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const { exitCode, stderr } = await runCli(
["render", "AAAAAAAAAAAAA", "--resolution", "invalid"],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("valid number");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
+415 -6
View File
@@ -3,13 +3,21 @@
import { mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
import {
bootstrap,
CasNodeNotFoundError,
computeHash,
createVariableStore,
gc,
getSchema,
InvalidTagFormatError,
InvalidVariableNameError,
putSchema,
refs,
render,
TagLabelConflictError,
VariableNotFoundError,
validate,
verify,
walk,
@@ -18,10 +26,19 @@ import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Argument parsing ----
type Flags = Record<string, string | boolean>;
type Flags = Record<string, string | boolean | string[]>;
/** Flags that consume the next token as their value. All others are boolean. */
const VALUE_FLAGS = new Set(["store", "format"]);
const VALUE_FLAGS = new Set([
"store",
"format",
"var-db",
"tag",
"schema",
"resolution",
"decay",
"epsilon",
]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
const flags: Flags = {};
@@ -34,7 +51,19 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
if (VALUE_FLAGS.has(key)) {
const next = argv[i + 1];
if (next !== undefined && !next.startsWith("--")) {
flags[key] = next;
// Handle repeatable flags (like --tag)
if (key === "tag") {
const existing = flags[key];
if (Array.isArray(existing)) {
existing.push(next);
} else if (typeof existing === "string") {
flags[key] = [existing, next];
} else {
flags[key] = [next];
}
} else {
flags[key] = next;
}
i++;
} else {
flags[key] = true;
@@ -57,6 +86,10 @@ const storePath =
typeof flags.store === "string" ? flags.store : defaultStorePath;
const compact = flags.json === true;
const defaultVarDbPath = join(storePath, "variables.db");
const varDbPath =
typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath;
// ---- Helpers ----
function out(data: unknown): void {
@@ -80,6 +113,93 @@ function openStore(): Store {
return createFsStore(resolve(storePath));
}
function openVarStore(): VariableStore {
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
return createVariableStore(resolve(varDbPath), store);
}
/**
* Get the Variable schema's CAS hash
* This is the type hash used in JSON envelopes
*/
async function getVariableSchemaHash(): Promise<Hash> {
const store = openStore();
// Define the Variable JSON Schema (updated for new model with composite key)
const variableSchema: JSONSchema = {
title: "Variable",
type: "object",
properties: {
name: { type: "string" },
schema: { type: "string" },
value: { type: "string" },
created: { type: "number" },
updated: { type: "number" },
tags: { type: "object" },
labels: { type: "array", items: { type: "string" } },
},
required: [
"name",
"schema",
"value",
"created",
"updated",
"tags",
"labels",
],
};
// Compute hash or retrieve from store
const hash = await putSchema(store, variableSchema);
return hash;
}
/**
* Wrap Variable output in JSON envelope
*/
async function wrapVariableEnvelope(
variable: unknown,
): Promise<{ type: Hash; value: unknown }> {
const typeHash = await getVariableSchemaHash();
return {
type: typeHash,
value: variable,
};
}
/**
* Parse tag/label arguments
* Returns: { tags: Record<string, string>, labels: string[], deleteNames: string[] }
*/
function parseTagsLabels(args: string[]): {
tags: Record<string, string>;
labels: string[];
deleteNames: string[];
} {
const tags: Record<string, string> = {};
const labels: string[] = [];
const deleteNames: string[] = [];
for (const arg of args) {
if (arg.startsWith(":")) {
// Deletion syntax: :name
deleteNames.push(arg.slice(1));
} else if (arg.includes(":")) {
// Tag: key:value (split on first colon)
const colonIdx = arg.indexOf(":");
const key = arg.slice(0, colonIdx);
const value = arg.slice(colonIdx + 1);
tags[key] = value;
} else {
// Label: bare identifier
labels.push(arg);
}
}
return { tags, labels, deleteNames };
}
// ---- Commands ----
async function cmdInit(): Promise<void> {
@@ -237,6 +357,53 @@ async function cmdHash(args: string[]): Promise<void> {
console.log(hash);
}
async function cmdRender(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) {
die(
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]",
);
}
const store = openStore();
// Parse numeric options
const resolution =
typeof flags.resolution === "string"
? Number.parseFloat(flags.resolution)
: undefined;
const decay =
typeof flags.decay === "string"
? Number.parseFloat(flags.decay)
: undefined;
const epsilon =
typeof flags.epsilon === "string"
? Number.parseFloat(flags.epsilon)
: undefined;
// Validate numeric values
if (resolution !== undefined && Number.isNaN(resolution)) {
die("--resolution must be a valid number");
}
if (decay !== undefined && Number.isNaN(decay)) {
die("--decay must be a valid number");
}
if (epsilon !== undefined && Number.isNaN(epsilon)) {
die("--epsilon must be a valid number");
}
try {
const output = render(store, hash, { resolution, decay, epsilon });
// Output to stdout without JSON wrapping (raw YAML)
process.stdout.write(output);
} catch (error) {
if (error instanceof Error) {
die(error.message);
}
die(String(error));
}
}
async function cmdCat(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas cat <hash>");
@@ -250,6 +417,203 @@ async function cmdCat(args: string[]): Promise<void> {
}
}
async function cmdVarSet(args: string[]): Promise<void> {
const name = args[0];
const value = args[1];
const tagFlags = flags.tag;
if (!name || !value) {
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
}
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
const tagArgs = Array.isArray(tagFlags)
? tagFlags
: typeof tagFlags === "string"
? [tagFlags]
: [];
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
// Check for conflicts in initial tags/labels
if (deleteNames.length > 0) {
die("Error: Cannot use deletion syntax (:name) in var set");
}
// If --tag flags are provided at all, always pass options to replace tags/labels
// If no --tag flags, pass undefined to preserve existing tags/labels
const options =
tagArgs.length > 0
? {
tags: Object.keys(tags).length > 0 ? tags : {},
labels: labels.length > 0 ? labels : [],
}
: undefined;
const variable = varStore.set(name, value, options);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof InvalidVariableNameError ||
e instanceof CasNodeNotFoundError ||
e instanceof TagLabelConflictError
) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarGet(args: string[]): Promise<void> {
const name = args[0];
const schema = flags.schema as string | undefined;
if (!name || !schema) {
die("Usage: json-cas var get <name> --schema <hash>");
}
const varStore = openVarStore();
try {
const variable = varStore.get(name, schema);
if (variable === null) {
die(`Error: Variable not found: name=${name}, schema=${schema}`);
}
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} finally {
varStore.close();
}
}
async function cmdVarDelete(args: string[]): Promise<void> {
const name = args[0];
const schema = flags.schema as string | undefined;
if (!name) {
die("Usage: json-cas var delete <name> [--schema <hash>]");
}
const varStore = openVarStore();
try {
if (schema !== undefined) {
// Precise deletion: remove specific (name, schema) variant
const variable = varStore.remove(name, schema);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} else {
// Batch deletion: remove all variants for this name
const variables = varStore.remove(name);
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
}
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarTag(args: string[]): Promise<void> {
const name = args[0];
const schema = flags.schema as string | undefined;
if (!name || !schema) {
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const tagArgs = args.slice(1);
if (tagArgs.length === 0) {
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
}
const varStore = openVarStore();
try {
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
const variable = varStore.tag(name, schema, {
add: Object.keys(tags).length > 0 ? tags : undefined,
addLabels: labels.length > 0 ? labels : undefined,
delete: deleteNames.length > 0 ? deleteNames : undefined,
});
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
e instanceof TagLabelConflictError ||
e instanceof InvalidTagFormatError
) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarList(args: string[]): Promise<void> {
const namePrefix = args[0] ?? "";
const schema = flags.schema as string | undefined;
const tagFlags = flags.tag;
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
const tagArgs = Array.isArray(tagFlags)
? tagFlags
: typeof tagFlags === "string"
? [tagFlags]
: [];
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
// Check for invalid deletion syntax in filters
if (deleteNames.length > 0) {
die("Error: Cannot use deletion syntax (:name) in var list filters");
}
const variables = varStore.list({
namePrefix,
schema,
tags: Object.keys(tags).length > 0 ? tags : undefined,
labels: labels.length > 0 ? labels : undefined,
});
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
} catch (e) {
if (e instanceof InvalidVariableNameError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdGc(_args: string[]): Promise<void> {
const store = createFsStore(storePath);
const varStore = createVariableStore(varDbPath, store);
try {
const stats = gc(store, varStore);
out(stats);
} finally {
varStore.close();
}
}
function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
@@ -268,11 +632,24 @@ Commands:
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
render <hash> [options] Render node as YAML with resolution decay
cat <hash> [--payload] Output node (--payload for payload only)
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
var delete <name> [--schema <hash>] Delete variable(s)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables
var tag <name> --schema <hash> <operations...> Modify tags/labels
gc Run garbage collection
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--json Compact JSON output`);
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)`);
}
// ---- Dispatch ----
@@ -342,10 +719,42 @@ switch (cmd) {
await cmdHash(rest);
break;
case "render":
await cmdRender(rest);
break;
case "cat":
await cmdCat(rest);
break;
case "var": {
const [sub, ...subRest] = rest;
switch (sub) {
case "set":
await cmdVarSet(subRest);
break;
case "get":
await cmdVarGet(subRest);
break;
case "delete":
await cmdVarDelete(subRest);
break;
case "tag":
await cmdVarTag(subRest);
break;
case "list":
await cmdVarList(subRest);
break;
default:
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
}
break;
}
case "gc":
await cmdGc(rest);
break;
default:
die(`Unknown command: ${cmd}`);
}
File diff suppressed because it is too large Load Diff
+39
View File
@@ -5,6 +5,7 @@ import {
readdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
@@ -175,6 +176,44 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return typeIndex.get(typeHash) ?? [];
},
listAll(): Hash[] {
return Array.from(data.keys());
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
data.delete(hash);
// Delete file
try {
unlinkSync(join(dir, `${hash}.bin`));
} catch {
// ignore if file doesn't exist
}
// Remove from type index
const list = typeIndex.get(node.type);
if (list) {
const idx = list.indexOf(hash);
if (idx !== -1) {
list.splice(idx, 1);
}
if (list.length === 0) {
typeIndex.delete(node.type);
// Delete empty index file
try {
unlinkSync(join(indexDir, node.type));
} catch {
// ignore
}
} else {
// Rewrite index file
const body = `${list.join("\n")}\n`;
writeFileSync(join(indexDir, node.type), body, "utf8");
}
}
}
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
+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,
};
}
+13
View File
@@ -2,7 +2,9 @@ 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 { type RenderOptions, render } from "./render.js";
export type { JSONSchema } from "./schema.js";
export {
getSchema,
@@ -14,4 +16,15 @@ export {
} from "./schema.js";
export { createMemoryStore } from "./store.js";
export type { CasNode, Hash, Store } from "./types.js";
export type { Variable } from "./variable.js";
export {
CasNodeNotFoundError,
createVariableStore,
InvalidTagFormatError,
InvalidVariableNameError,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
export { verify } from "./verify.js";
+8
View File
@@ -27,6 +27,14 @@ export class MemStore implements BootstrapCapableStore {
return this.#inner.listByType(typeHash);
}
listAll(): Hash[] {
return this.#inner.listAll();
}
delete(hash: Hash): void {
this.#inner.delete(hash);
}
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash> {
return this.#inner[BOOTSTRAP_STORE](payload);
}
+935
View File
@@ -0,0 +1,935 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { render } from "./render.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
import type { Hash } from "./types.js";
describe("Suite 1: Basic Rendering (No Nesting)", () => {
test("1.1 Render Simple Primitives", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, { type: "string" });
const hash = await store.put(textSchema, "hello");
const output = render(store, hash, { resolution: 1.0 });
expect(output).toContain("hello");
expect(output.trim()).toBeTruthy();
});
test("1.2 Render Object Node (Flat)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const objSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
count: { type: "number" },
},
});
const hash = await store.put(objSchema, { name: "test", count: 42 });
const output = render(store, hash, { resolution: 1.0 });
expect(output).toContain("name");
expect(output).toContain("test");
expect(output).toContain("count");
expect(output).toContain("42");
});
test("1.3 Render Array Node (Flat)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const arraySchema = await putSchema(store, {
type: "array",
items: { type: "number" },
});
const hash = await store.put(arraySchema, [1, 2, 3]);
const output = render(store, hash, { resolution: 1.0 });
expect(output).toContain("1");
expect(output).toContain("2");
expect(output).toContain("3");
});
test("1.4 Render with resolution=0 (Force Reference)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, { type: "string" });
const hash = await store.put(textSchema, "hello");
const output = render(store, hash, { resolution: 0 });
expect(output.trim()).toBe(`cas:${hash}`);
});
test("1.5 Render Non-existent Hash", () => {
const store = createMemoryStore();
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
// Non-existent node renders as cas: reference
const output = render(store, fakeHash);
expect(output.trim()).toBe(`cas:${fakeHash}`);
});
});
describe("Suite 2: Resolution Decay Model", () => {
test("2.1 Single-level Nesting with Default Decay", async () => {
const store = createMemoryStore();
await bootstrap(store);
const childSchema = await putSchema(store, {
type: "object",
properties: {
content: { type: "string" },
},
});
const childHash = await store.put(childSchema, { content: "leaf" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
title: { type: "string" },
child: { type: "string", format: "cas_ref" },
},
});
const parentHash = await store.put(parentSchema, {
title: "root",
child: childHash,
});
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("title");
expect(output).toContain("root");
expect(output).toContain("content");
expect(output).toContain("leaf");
});
test("2.2 Multi-level Nesting Reaches Epsilon", async () => {
const store = createMemoryStore();
await bootstrap(store);
const leafSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 8-level chain
let currentHash: Hash | null = null;
for (let i = 7; i >= 0; i--) {
currentHash = await store.put(leafSchema, {
value: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
// At depth 7: resolution = 0.5^7 = 0.0078125 <= 0.01
expect(output).toContain("value");
expect(output).toContain("0"); // root level
// Should contain cas: reference at deep level
expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
});
test("2.3 High Decay (Quick Cutoff)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
child: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 3-level nested structure
const level2Hash = await store.put(nodeSchema, { level: 2, child: null });
const level1Hash = await store.put(nodeSchema, {
level: 1,
child: level2Hash,
});
const rootHash = await store.put(nodeSchema, {
level: 0,
child: level1Hash,
});
const output = render(store, rootHash, {
resolution: 1.0,
decay: 0.1,
epsilon: 0.01,
});
expect(output).toContain("level");
expect(output).toContain("0"); // root
expect(output).toContain("1"); // level 1 (0.1 > 0.01)
// Level 2 should be reference (0.01 <= 0.01)
expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
});
test("2.4 Low Decay (Deep Expansion)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 10-level chain
let currentHash: Hash | null = null;
for (let i = 9; i >= 0; i--) {
currentHash = await store.put(nodeSchema, {
level: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 1.0,
decay: 0.9,
epsilon: 0.01,
});
// All 10 levels should be expanded (0.9^10 ≈ 0.349 > 0.01)
for (let i = 0; i < 10; i++) {
expect(output).toContain(`${i}`);
}
});
test("2.5 Starting Resolution Below 1.0", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 5-level chain
let currentHash: Hash | null = null;
for (let i = 4; i >= 0; i--) {
currentHash = await store.put(nodeSchema, {
level: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 0.5,
decay: 0.5,
epsilon: 0.01,
});
// resolution sequence: 0.5, 0.25, 0.125, 0.0625, 0.03125 (all > 0.01)
expect(output).toContain("0");
expect(output).toContain("1");
expect(output).toContain("2");
expect(output).toContain("3");
});
});
describe("Suite 3: Complex Graph Structures", () => {
test("3.1 Multiple Child References", async () => {
const store = createMemoryStore();
await bootstrap(store);
const itemSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
},
});
const item1 = await store.put(itemSchema, { name: "item1" });
const item2 = await store.put(itemSchema, { name: "item2" });
const item3 = await store.put(itemSchema, { name: "item3" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
items: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
});
const parentHash = await store.put(parentSchema, {
items: [item1, item2, item3],
});
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("item1");
expect(output).toContain("item2");
expect(output).toContain("item3");
});
test("3.2 Object with Multiple cas_ref Fields", async () => {
const store = createMemoryStore();
await bootstrap(store);
const childSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "string" },
},
});
const leftHash = await store.put(childSchema, { value: "left" });
const rightHash = await store.put(childSchema, { value: "right" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
left: { type: "string", format: "cas_ref" },
right: { type: "string", format: "cas_ref" },
data: { type: "string" },
},
});
const parentHash = await store.put(parentSchema, {
left: leftHash,
right: rightHash,
data: "node",
});
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("left");
expect(output).toContain("right");
expect(output).toContain("node");
});
test("3.3 Cycle Detection", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
ref: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
const hashA = await store.put(nodeSchema, { name: "A", ref: null });
const hashB = await store.put(nodeSchema, { name: "B", ref: hashA });
// Manually update A to reference B (simulate cycle)
// Note: In practice, this requires store manipulation
// For this test, we'll create a simpler case
const output = render(store, hashB, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
// Should not infinite loop
expect(output).toContain("B");
expect(output).toContain("A");
});
test("3.4 DAG (Shared Descendant)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const leafSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "string" },
},
});
const sharedLeaf = await store.put(leafSchema, { value: "shared" });
const branchSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
child: { type: "string", format: "cas_ref" },
},
});
const branchA = await store.put(branchSchema, {
name: "A",
child: sharedLeaf,
});
const branchB = await store.put(branchSchema, {
name: "B",
child: sharedLeaf,
});
const rootSchema = await putSchema(store, {
type: "object",
properties: {
left: { type: "string", format: "cas_ref" },
right: { type: "string", format: "cas_ref" },
},
});
const rootHash = await store.put(rootSchema, {
left: branchA,
right: branchB,
});
const output = render(store, rootHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("A");
expect(output).toContain("B");
expect(output).toContain("shared");
});
test("3.5 Deep Tree", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "number" },
left: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
right: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create binary tree (just 5 levels for test speed)
async function createTree(depth: number, value: number): Promise<Hash> {
if (depth === 0) {
return store.put(nodeSchema, { value, left: null, right: null });
}
const left = await createTree(depth - 1, value * 2);
const right = await createTree(depth - 1, value * 2 + 1);
return store.put(nodeSchema, { value, left, right });
}
const rootHash = await createTree(5, 1);
const output = render(store, rootHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
// Should complete without error
expect(output).toContain("value");
});
});
describe("Suite 4: Epsilon Boundary Cases", () => {
test("4.1 Resolution Exactly at Epsilon", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, { type: "string" });
const hash = await store.put(textSchema, "test");
const output = render(store, hash, {
resolution: 0.01,
decay: 0.5,
epsilon: 0.01,
});
expect(output.trim()).toBe(`cas:${hash}`);
});
test("4.2 Resolution Just Above Epsilon", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, { type: "string" });
const hash = await store.put(textSchema, "test");
const output = render(store, hash, {
resolution: 0.0100001,
epsilon: 0.01,
});
expect(output).toContain("test");
expect(output).not.toContain("cas:");
});
test("4.3 Very Small Epsilon (Deep Expansion)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 15-level chain
let currentHash: Hash | null = null;
for (let i = 14; i >= 0; i--) {
currentHash = await store.put(nodeSchema, {
level: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.000001,
});
// Many levels should be expanded
expect(output).toContain("0");
expect(output).toContain("5");
expect(output).toContain("10");
});
test("4.4 Zero Epsilon (Never Prune)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 20-level chain
let currentHash: Hash | null = null;
for (let i = 19; i >= 0; i--) {
currentHash = await store.put(nodeSchema, {
level: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0,
});
// All levels should be present
expect(output).toContain("0");
expect(output).toContain("10");
expect(output).toContain("19");
});
});
describe("Suite 5: YAML Output Format", () => {
test("5.1 Valid YAML Syntax", async () => {
const store = createMemoryStore();
await bootstrap(store);
const objSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
count: { type: "number" },
},
});
const hash = await store.put(objSchema, { name: "test", count: 42 });
const output = render(store, hash);
// Basic YAML validation - should have key: value pairs
expect(output).toMatch(/\w+:/);
});
test("5.2 Nested Object Indentation", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nestedSchema = await putSchema(store, {
type: "object",
properties: {
outer: {
type: "object",
properties: {
inner: { type: "string" },
},
},
},
});
const hash = await store.put(nestedSchema, {
outer: { inner: "value" },
});
const output = render(store, hash);
// Should have proper indentation (2 spaces)
expect(output).toContain("outer");
expect(output).toContain("inner");
expect(output).toContain("value");
});
test("5.3 Array Rendering", async () => {
const store = createMemoryStore();
await bootstrap(store);
const arraySchema = await putSchema(store, {
type: "array",
items: { type: "number" },
});
const hash = await store.put(arraySchema, [1, 2, 3]);
const output = render(store, hash);
// YAML array format
expect(output).toMatch(/[-[].*[1-3]/);
});
test("5.4 CAS Reference in YAML", async () => {
const store = createMemoryStore();
await bootstrap(store);
const childSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "string" },
},
});
const childHash = await store.put(childSchema, { value: "child" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
child: { type: "string", format: "cas_ref" },
},
});
const parentHash = await store.put(parentSchema, { child: childHash });
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.1,
epsilon: 0.5,
});
// Child should be rendered as cas: reference
expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
});
test("5.5 Special Characters Escaping", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, { type: "string" });
const hash = await store.put(textSchema, "line1\nline2: value");
const output = render(store, hash);
// Should handle newlines and colons
expect(output).toBeTruthy();
});
test("5.6 Null Handling", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nullableSchema = await putSchema(store, {
type: "object",
properties: {
ref: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
const hash = await store.put(nullableSchema, { ref: null });
const output = render(store, hash);
expect(output).toContain("null");
});
});
describe("Suite 6: Schema Integration", () => {
test("6.1 Detect cas_ref Fields via Schema", async () => {
const store = createMemoryStore();
await bootstrap(store);
const childSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "string" },
},
});
const childHash = await store.put(childSchema, { value: "child" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
link: { type: "string", format: "cas_ref" },
},
});
const parentHash = await store.put(parentSchema, { link: childHash });
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("child");
});
test("6.2 Non-cas_ref String Not Expanded", async () => {
const store = createMemoryStore();
await bootstrap(store);
const objSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
},
});
const hash = await store.put(objSchema, { name: "ABC123XYZ9012" });
const output = render(store, hash);
// Should be plain string, not expanded
expect(output).toContain("ABC123XYZ9012");
expect(output).not.toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/);
});
test("6.3 Array of cas_ref", async () => {
const store = createMemoryStore();
await bootstrap(store);
const itemSchema = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
},
});
const item1 = await store.put(itemSchema, { name: "item1" });
const item2 = await store.put(itemSchema, { name: "item2" });
const arraySchema = await putSchema(store, {
type: "array",
items: { type: "string", format: "cas_ref" },
});
const arrayHash = await store.put(arraySchema, [item1, item2]);
const output = render(store, arrayHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("item1");
expect(output).toContain("item2");
});
test("6.4 anyOf with cas_ref (Nullable Reference)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const childSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "string" },
},
});
const childHash = await store.put(childSchema, { value: "child" });
const parentSchema = await putSchema(store, {
type: "object",
properties: {
ref: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
const parentHash = await store.put(parentSchema, { ref: childHash });
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toContain("child");
});
test("6.5 Schema-less Node (Bootstrap Node)", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const output = render(store, metaHash);
// Should render without recursive expansion
expect(output).toBeTruthy();
});
});
describe("Suite 7: Error Handling", () => {
test("7.1 Missing Referenced Node", async () => {
const store = createMemoryStore();
await bootstrap(store);
const parentSchema = await putSchema(store, {
type: "object",
properties: {
child: { type: "string", format: "cas_ref" },
},
});
const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash;
const parentHash = await store.put(parentSchema, { child: fakeChildHash });
const output = render(store, parentHash);
// Should render missing ref as cas:<hash>
expect(output).toContain(`cas:${fakeChildHash}`);
});
test("7.3 Invalid Resolution Parameter", () => {
const store = createMemoryStore();
const fakeHash = "AAAAAAAAAAAAA" as Hash;
expect(() => render(store, fakeHash, { resolution: -1 })).toThrow();
});
test("7.4 Invalid Decay Parameter", () => {
const store = createMemoryStore();
const fakeHash = "AAAAAAAAAAAAA" as Hash;
expect(() => render(store, fakeHash, { decay: 1.5 })).toThrow();
});
test("7.5 Invalid Epsilon Parameter", () => {
const store = createMemoryStore();
const fakeHash = "AAAAAAAAAAAAA" as Hash;
expect(() => render(store, fakeHash, { epsilon: -0.01 })).toThrow();
});
});
describe("Suite 8: Performance & Edge Cases", () => {
test("8.1 Large Payload", async () => {
const store = createMemoryStore();
await bootstrap(store);
const arraySchema = await putSchema(store, {
type: "array",
items: {
type: "object",
properties: {
id: { type: "number" },
name: { type: "string" },
},
},
});
const largeArray = Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `item${i}`,
}));
const hash = await store.put(arraySchema, largeArray);
const start = Date.now();
const output = render(store, hash);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(5000);
expect(output).toBeTruthy();
});
test("8.2 Wide Fan-out", async () => {
const store = createMemoryStore();
await bootstrap(store);
const itemSchema = await putSchema(store, {
type: "object",
properties: {
value: { type: "number" },
},
});
const children: Hash[] = [];
for (let i = 0; i < 100; i++) {
const hash = await store.put(itemSchema, { value: i });
children.push(hash);
}
const parentSchema = await putSchema(store, {
type: "array",
items: { type: "string", format: "cas_ref" },
});
const parentHash = await store.put(parentSchema, children);
const output = render(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
expect(output).toBeTruthy();
});
test("8.3 Empty Payload", async () => {
const store = createMemoryStore();
await bootstrap(store);
const emptySchema = await putSchema(store, { type: "object" });
const hash = await store.put(emptySchema, {});
const output = render(store, hash);
expect(output.trim()).toMatch(/\{\}/);
});
test("8.4 Unicode in Payload", async () => {
const store = createMemoryStore();
await bootstrap(store);
const textSchema = await putSchema(store, {
type: "object",
properties: {
text: { type: "string" },
},
});
const hash = await store.put(textSchema, { text: "你好世界 🌍" });
const output = render(store, hash);
expect(output).toContain("你好世界");
expect(output).toContain("🌍");
});
});
+221
View File
@@ -0,0 +1,221 @@
import { refs } from "./schema.js";
import type { Hash, Store } from "./types.js";
export type RenderOptions = {
resolution?: number; // (0, 1], default 1.0
decay?: number; // (0, 1], default 0.5
epsilon?: number; // >= 0, default 0.01
};
const DEFAULT_RESOLUTION = 1.0;
const DEFAULT_DECAY = 0.5;
const DEFAULT_EPSILON = 0.01;
// Small tolerance for floating point comparison
const FLOAT_TOLERANCE = 1e-10;
/**
* Render a CAS node as YAML with resolution-based decay.
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
*/
export function render(
store: Store,
hash: Hash,
options?: RenderOptions,
): string {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
// Validate parameters
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
if (decay <= 0 || decay > 1) {
throw new Error("decay must be in (0, 1]");
}
if (epsilon < 0) {
throw new Error("epsilon must be >= 0");
}
const visited = new Set<Hash>();
return renderNode(store, hash, resolution, decay, epsilon, visited);
}
function renderNode(
store: Store,
hash: Hash,
currentResolution: number,
decay: number,
epsilon: number,
visited: Set<Hash>,
): string {
// Check if resolution is below threshold (with floating point tolerance)
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
return `cas:${hash}`;
}
// Fetch the node
const node = store.get(hash);
if (node === null) {
// Missing node - render as cas: reference
return `cas:${hash}`;
}
// Cycle detection
if (visited.has(hash)) {
return `cas:${hash}`;
}
visited.add(hash);
// Get references from this node's schema
const nodeRefs = refs(store, node);
const refSet = new Set(nodeRefs);
// Calculate child resolution for next level
const childResolution = currentResolution * decay;
// Render the payload with recursive expansion of cas_ref fields
const rendered = renderValue(
store,
node.payload,
refSet,
childResolution,
decay,
epsilon,
visited,
);
visited.delete(hash);
return rendered;
}
function renderValue(
store: Store,
value: unknown,
refHashes: Set<Hash>,
childResolution: number,
decay: number,
epsilon: number,
visited: Set<Hash>,
): string {
// Handle null
if (value === null) {
return "null\n";
}
// Handle primitives
if (typeof value === "string") {
// Check if this string is a cas_ref
if (refHashes.has(value as Hash)) {
// Recursively render the referenced node
return renderNode(
store,
value as Hash,
childResolution,
decay,
epsilon,
visited,
);
}
// Otherwise, render as YAML string
return toYamlString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return `${value}\n`;
}
// Handle arrays
if (Array.isArray(value)) {
if (value.length === 0) {
return "[]\n";
}
const items = value.map((item) => {
const itemYaml = renderValue(
store,
item,
refHashes,
childResolution,
decay,
epsilon,
visited,
);
return indent(itemYaml.trim(), 2);
});
return `- ${items.join("\n- ")}\n`;
}
// Handle objects
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) {
return "{}\n";
}
const pairs = keys.map((key) => {
const val = obj[key];
const valYaml = renderValue(
store,
val,
refHashes,
childResolution,
decay,
epsilon,
visited,
);
const trimmedVal = valYaml.trim();
// If value is multiline, indent it
if (trimmedVal.includes("\n")) {
return `${key}:\n${indent(trimmedVal, 2)}`;
}
return `${key}: ${trimmedVal}`;
});
return `${pairs.join("\n")}\n`;
}
return "null\n";
}
function toYamlString(str: string): string {
// Handle special characters
if (
str.includes("\n") ||
str.includes(":") ||
str.includes("#") ||
str.includes("[") ||
str.includes("]") ||
str.includes("{") ||
str.includes("}") ||
str.includes("'") ||
str.includes('"') ||
str.startsWith(" ") ||
str.endsWith(" ")
) {
// Use double-quoted string with escaping
const escaped = str
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n");
return `"${escaped}"\n`;
}
return `${str}\n`;
}
function indent(text: string, spaces: number): string {
const prefix = " ".repeat(spaces);
return text
.split("\n")
.map((line) => (line ? prefix + line : line))
.join("\n");
}
+19
View File
@@ -52,6 +52,25 @@ export function createMemoryStore(): BootstrapCapableStore {
return set ? [...set] : [];
},
listAll(): Hash[] {
return Array.from(data.keys());
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
data.delete(hash);
// Remove from type index
const set = byType.get(node.type);
if (set) {
set.delete(hash);
if (set.size === 0) {
byType.delete(node.type);
}
}
}
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
+2
View File
@@ -24,4 +24,6 @@ export type Store = {
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
listByType(typeHash: Hash): Hash[];
listAll(): Hash[];
delete(hash: Hash): void;
};
File diff suppressed because it is too large Load Diff
+719
View File
@@ -0,0 +1,719 @@
import { Database } from "bun:sqlite";
import type { Hash, Store } from "./types.js";
import type { Variable } from "./variable.js";
/**
* Custom error types for variable operations
*/
export class VariableNotFoundError extends Error {
constructor(
public variableName: string,
public variableSchema: Hash,
) {
super(`Variable not found: name=${variableName}, schema=${variableSchema}`);
this.name = "VariableNotFoundError";
}
}
export class InvalidVariableNameError extends Error {
constructor(
public variableName: string,
public reason: string,
) {
super(`Invalid variable name "${variableName}": ${reason}`);
this.name = "InvalidVariableNameError";
}
}
export class SchemaMismatchError extends Error {
constructor(
public expected: string,
public actual: string,
) {
super(`Schema mismatch: expected ${expected}, got ${actual}`);
this.name = "SchemaMismatchError";
}
}
export class CasNodeNotFoundError extends Error {
constructor(hash: string) {
super(`CAS node not found: ${hash}`);
this.name = "CasNodeNotFoundError";
}
}
export class TagLabelConflictError extends Error {
constructor(
public conflictName: string,
public existingType: "tag" | "label",
public attemptedType: "tag" | "label",
) {
super(`Conflict: '${conflictName}' already exists as a ${existingType}`);
this.name = "TagLabelConflictError";
}
}
export class InvalidTagFormatError extends Error {
constructor(tag: string) {
super(`Invalid tag format: ${tag}`);
this.name = "InvalidTagFormatError";
}
}
/**
* Variable store with SQLite backend
*/
export class VariableStore {
private db: Database;
constructor(
dbPath: string,
private casStore: Store,
) {
this.db = new Database(dbPath, { create: true });
// Enable foreign keys
this.db.exec("PRAGMA foreign_keys = ON");
this.initDb();
}
private initDb(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS variables (
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
PRIMARY KEY (name, schema)
);
CREATE INDEX IF NOT EXISTS idx_var_name ON variables(name);
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
CREATE TABLE IF NOT EXISTS variable_tags (
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (variable_name, variable_schema, key),
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS variable_labels (
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (variable_name, variable_schema, name),
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key);
CREATE INDEX IF NOT EXISTS idx_var_tag_key_value ON variable_tags(key, value);
CREATE INDEX IF NOT EXISTS idx_var_label_name ON variable_labels(name);
`);
}
/**
* Validate variable name format
*/
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._-]+ and no empty segments
const segments = name.split("/");
for (const segment of segments) {
if (segment === "") {
throw new InvalidVariableNameError(
name,
"Name contains empty segment (consecutive slashes //)",
);
}
// Check for invalid characters
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
throw new InvalidVariableNameError(
name,
`Segment "${segment}" contains invalid characters (only 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
};