Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f2906908c | |||
| 077eaa6f6d | |||
| 7e23d911a4 | |||
| 301b05c212 | |||
| 22fce0ac66 | |||
| fddbb1549e | |||
| 109aaab9b8 | |||
| 906a6dfd1c | |||
| 5e7db0ef6b | |||
| 31f84a7ab0 | |||
| 793a5c619d | |||
| b89e31f468 | |||
| b9131c728e | |||
| cd338822f2 | |||
| 7242588dd9 | |||
| c34a8b3c58 | |||
| 08b143ea0b | |||
| 1269de5b96 | |||
| 263fe40146 | |||
| aefd93c33e | |||
| 76dab6737c | |||
| 1e8ccb8962 | |||
| cf716c5115 | |||
| 98dc91e848 | |||
| 064c9afa1e | |||
| 1ea058a7a6 | |||
| c20c6df2bf | |||
| b2ee62dce2 | |||
| 1dacd699d5 | |||
| 0e38fd3ea9 | |||
| e00a23dd80 | |||
| d2225c8cdf | |||
| 4d7b439aaa | |||
| ccca0e60d1 | |||
| b7aa90d8e6 | |||
| 9a1954f6f9 | |||
| b062fcbc44 | |||
| 0706307e85 | |||
| d57a454b78 | |||
| 34847cae59 | |||
| 054d78296a | |||
| 8f54dcfa7c | |||
| 7828dd1c41 | |||
| a30af6efb5 | |||
| 712f930072 | |||
| cfe791180b | |||
| 09526b63da | |||
| 00f191e105 | |||
| 52cb7a30ba | |||
| 1b53cf5ff8 | |||
| 17ed619900 | |||
| bad62a82a9 | |||
| 4989cd31ba | |||
| 38aad696fc | |||
| fb81f5a429 | |||
| 5fc475704b | |||
| 6e77e4a110 | |||
| a3a21b153c |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": "@changesets/changelog-github",
|
||||
"changelog": ["@changesets/changelog-github", { "repo": "uncaged/json-cas" }],
|
||||
"commit": false,
|
||||
"fixed": [["@uncaged/*"]],
|
||||
"linked": [],
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Sync README
|
||||
|
||||
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
|
||||
@@ -1,3 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.d.ts.map
|
||||
*.tsbuildinfo
|
||||
.worktrees/
|
||||
|
||||
@@ -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." }
|
||||
@@ -0,0 +1,198 @@
|
||||
name: "solve-issue"
|
||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes issue and outputs a TDD test spec"
|
||||
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: |
|
||||
On first run (no previous steps):
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
|
||||
On subsequent runs (bounced back by tester with fix_spec):
|
||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||
2. Revise the test spec accordingly
|
||||
|
||||
After producing the test spec:
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
2. Put the hash in frontmatter.plan (required when $status=ready)
|
||||
3. Set repoPath to the absolute path of the repository root
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
required: [$status]
|
||||
developer:
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The repo path and other details are provided in your task prompt.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. cd into the repo path provided in your task prompt
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. First time (no existing branch):
|
||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
4. If bounced back from reviewer or tester (branch already exists):
|
||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||
8. Write tests first based on the spec
|
||||
9. Implement the code to make tests pass
|
||||
10. Ensure `bun run build` passes with no errors
|
||||
11. Run `bun test` to verify all tests pass
|
||||
|
||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
||||
or repeated attempts fail), set $status=failed with a reason.
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
reviewer:
|
||||
description: "Code standards compliance check"
|
||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||
|
||||
Then perform code review:
|
||||
Hard checks (must all pass):
|
||||
3. `bun run build` — no build errors
|
||||
4. `bunx biome check` — no lint violations
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
|
||||
- Naming conventions, module boundaries, code style
|
||||
- No `console.log` in production code
|
||||
- No dynamic imports in production code
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
|
||||
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
||||
5. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
planner:
|
||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
||||
developer:
|
||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
||||
tester:
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||
@@ -0,0 +1,77 @@
|
||||
# CLAUDE.md — json-cas
|
||||
|
||||
Self-describing content-addressable storage with JSON Schema typed nodes.
|
||||
|
||||
## Project Structure
|
||||
|
||||
Monorepo with 4 packages under `packages/`:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `json-cas` | Core CAS engine — hashing, schema, store, verify, bootstrap |
|
||||
| `json-cas-fs` | Filesystem-backed CAS store |
|
||||
| `cli-json-cas` | CLI tool |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Bun
|
||||
- **Language:** TypeScript (strict mode, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
|
||||
- **Build:** `tsc --build` (composite project references)
|
||||
- **Test:** `bun test`
|
||||
- **Lint/Format:** Biome (`biome check .` / `biome format --write .`)
|
||||
- **Publish:** Changesets → npmjs (`@uncaged/*`)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun test # Run all tests
|
||||
bun run build # Build all packages
|
||||
bun run check # Biome lint
|
||||
bun run format # Biome format (auto-fix)
|
||||
```
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Strict mode** — no `any`, no unchecked index access, no implicit overrides
|
||||
- **`verbatimModuleSyntax`** — use `import type` for type-only imports
|
||||
- **Import paths** — use `.js` extension in imports (ESM convention with bundler resolution)
|
||||
- **Export style** — named exports only, re-export from `index.ts`
|
||||
|
||||
### Biome Rules
|
||||
|
||||
- `noConsole: "error"` globally (except `cli-json-cas`)
|
||||
- Recommended ruleset enabled
|
||||
- Auto-organize imports via `assist.actions.source.organizeImports`
|
||||
- Indent: 2 spaces
|
||||
|
||||
### Naming
|
||||
|
||||
- Types: `PascalCase` (`CasNode`, `Hash`, `Store`)
|
||||
- Functions: `camelCase` (`computeHash`, `createMemoryStore`)
|
||||
- Constants: `UPPER_SNAKE_CASE` (`BOOTSTRAP_STORE`)
|
||||
- Files: `kebab-case.ts`
|
||||
- Test files: co-located as `*.test.ts`
|
||||
|
||||
### Key Types
|
||||
|
||||
- `Hash` — 13-character uppercase Crockford Base32 string (XXH64)
|
||||
- `CasNode` — content-addressed node with schema
|
||||
- `Store` — abstract storage interface (get/put)
|
||||
|
||||
## Git
|
||||
|
||||
- Commit format: `type: description` (conventional commits)
|
||||
- Reference issues: `Fixes #N` / `Closes #N`
|
||||
- Author: `小橘 <xiaoju@shazhou.work>`
|
||||
|
||||
## Project Rules
|
||||
|
||||
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
|
||||
|
||||
## Before Submitting
|
||||
|
||||
1. `bun test` — all tests pass
|
||||
2. `bun run check` — no lint errors
|
||||
3. `bun run build` — builds cleanly
|
||||
@@ -1,3 +1,135 @@
|
||||
# json-cas
|
||||
|
||||
Self-describing content-addressable storage with JSON Schema typed nodes
|
||||
Self-describing content-addressable storage with JSON Schema typed nodes.
|
||||
|
||||
## Overview
|
||||
|
||||
json-cas is a monorepo for storing and validating JSON data in a content-addressable store (CAS). Each node has a typed payload: its `type` field is the hash of a JSON Schema node that describes the payload shape. Hashes are 13-character Crockford Base32 strings derived from XXH64 over deterministic CBOR encoding.
|
||||
|
||||
A bootstrap meta-schema is stored as a self-referencing seed node (`type === hash`). All other schemas are registered as nodes typed by that meta-schema. Payloads can reference other nodes via `format: "cas_ref"` fields; the library provides traversal, reference extraction, and integrity verification.
|
||||
|
||||
Use the in-memory store for tests and embedded apps, the filesystem store for persistence, and the CLI for local store management.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ cli-json-cas │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ json-cas-fs │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ json-cas │ (core)
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
| Layer | Package | Role |
|
||||
|-------|---------|------|
|
||||
| Core | `@uncaged/json-cas` | Hashing, schemas, stores, verify, bootstrap |
|
||||
| Storage | `@uncaged/json-cas-fs` | Filesystem-backed `Store` |
|
||||
| CLI | `@uncaged/cli-json-cas` | `json-cas` command-line tool |
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description | Type |
|
||||
|---------|-------------|------|
|
||||
| [`@uncaged/json-cas`](packages/json-cas/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
|
||||
| [`@uncaged/json-cas-fs`](packages/json-cas-fs/README.md) | Filesystem-backed CAS store | lib |
|
||||
| [`@uncaged/cli-json-cas`](packages/cli-json-cas/README.md) | CLI tool (`json-cas` binary) | cli |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd json-cas
|
||||
bun install --no-cache
|
||||
bun run build
|
||||
```
|
||||
|
||||
```typescript
|
||||
import {
|
||||
bootstrap,
|
||||
createMemoryStore,
|
||||
putSchema,
|
||||
validate,
|
||||
} from "@uncaged/json-cas";
|
||||
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const typeHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { message: { type: "string" } },
|
||||
required: ["message"],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const hash = await store.put(typeHash, { message: "hello" });
|
||||
const node = store.get(hash);
|
||||
console.log(validate(store, node!)); // true
|
||||
```
|
||||
|
||||
For a persistent store:
|
||||
|
||||
```typescript
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { bootstrap } from "@uncaged/json-cas";
|
||||
|
||||
const store = createFsStore("/path/to/store");
|
||||
await bootstrap(store);
|
||||
```
|
||||
|
||||
Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/README.md`](packages/cli-json-cas/README.md)).
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Binary: `json-cas` (from `@uncaged/cli-json-cas`). Default store: `~/.uncaged/json-cas`.
|
||||
|
||||
```
|
||||
Usage: json-cas [--store <path>] [--json] <command> [args]
|
||||
|
||||
Commands:
|
||||
init Create store dir and write bootstrap seed
|
||||
bootstrap Write meta-schema seed, print hash
|
||||
schema put <file.json> Register schema, print type hash
|
||||
schema get <type-hash> Print schema JSON
|
||||
schema list List all schemas (name + hash)
|
||||
schema validate <hash> Validate node against its schema
|
||||
put <type-hash> <file.json> Store node, print hash
|
||||
get <hash> Print node as JSON
|
||||
has <hash> Print true/false
|
||||
verify <hash> Verify integrity, print ok/corrupted
|
||||
refs <hash> List direct cas_ref edges
|
||||
walk <hash> [--format tree] Recursive traversal
|
||||
hash <type-hash> <file.json> Compute hash without storing (dry run)
|
||||
cat <hash> [--payload] Output node (--payload for payload only)
|
||||
|
||||
Flags:
|
||||
--store <path> Store directory (default: ~/.uncaged/json-cas)
|
||||
--json Compact JSON output
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun install --no-cache # install workspace dependencies
|
||||
bun run build # tsc --build (libs)
|
||||
bun run check # biome check
|
||||
bun run format # biome format --write
|
||||
bun test # run all package tests
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
Releases use [Changesets](https://github.com/changesets/changesets). From the repo root:
|
||||
|
||||
```bash
|
||||
bun run release # changeset version → build → publish to npm (@uncaged/*)
|
||||
```
|
||||
|
||||
Individual packages block `prepublishOnly` and expect releases via the workspace `release` script.
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"bun-types": "^1.3.14",
|
||||
"typescript": "^5.8.0",
|
||||
"ulidx": "^2.4.1",
|
||||
},
|
||||
},
|
||||
"packages/cli-json-cas": {
|
||||
"name": "@uncaged/cli-json-cas",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.3",
|
||||
"bin": {
|
||||
"json-cas": "./src/index.ts",
|
||||
},
|
||||
@@ -25,7 +26,7 @@
|
||||
},
|
||||
"packages/json-cas": {
|
||||
"name": "@uncaged/json-cas",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.3",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
@@ -34,19 +35,12 @@
|
||||
},
|
||||
"packages/json-cas-fs": {
|
||||
"name": "@uncaged/json-cas-fs",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.3",
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "workspace:^",
|
||||
"cborg": "^4.2.3",
|
||||
},
|
||||
},
|
||||
"packages/json-cas-workflow": {
|
||||
"name": "@uncaged/json-cas-workflow",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "workspace:^",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
@@ -127,8 +121,6 @@
|
||||
|
||||
"@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@workspace:packages/json-cas-fs"],
|
||||
|
||||
"@uncaged/json-cas-workflow": ["@uncaged/json-cas-workflow@workspace:packages/json-cas-workflow"],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
@@ -209,6 +201,8 @@
|
||||
|
||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
|
||||
@@ -291,6 +285,8 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ulidx": ["ulidx@2.4.1", "", { "dependencies": { "layerr": "^3.0.0" } }, "sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Sync README
|
||||
|
||||
When updating README.md files in this monorepo, follow these conventions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Root `README.md` — project overview and navigation hub
|
||||
- Per-package `packages/*/README.md` — each package self-contained
|
||||
|
||||
## Root README Structure
|
||||
|
||||
The root README should have these sections in order:
|
||||
|
||||
1. **Title and one-liner** — content-addressed storage for JSON with schema validation
|
||||
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
||||
3. **Architecture** — dependency layer diagram (text-based)
|
||||
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib)
|
||||
5. **Quick Start** — install, build, basic usage
|
||||
6. **CLI Reference** — brief command list, detailed usage in cli-json-cas README
|
||||
7. **Development** — bun install / build / check / test
|
||||
8. **Publishing** — changeset workflow (bun run release)
|
||||
|
||||
## Per-Package README Structure
|
||||
|
||||
Each package README should have:
|
||||
|
||||
1. **Title** — package name
|
||||
2. **One-line description** — matching package.json
|
||||
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
||||
4. **Installation** — bun add (for libs) or "included as binary" (for cli)
|
||||
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
||||
6. **CLI Usage** (cli packages) — command reference with examples
|
||||
7. **Internal Structure** — brief src/ file organization
|
||||
8. **Configuration** (if applicable)
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Gather current state
|
||||
For each package read:
|
||||
- package.json (name, version, description, dependencies, bin)
|
||||
- src/index.ts (public API exports)
|
||||
- Existing README.md (preserve hand-written content worth keeping)
|
||||
|
||||
### Step 2: Update root README
|
||||
- Ensure ALL packages in packages/ directory are listed in the table
|
||||
- Update CLI command reference from actual --help output
|
||||
- Keep Quick Start examples valid
|
||||
|
||||
### Step 3: Write/update each package README
|
||||
- Follow the per-package structure
|
||||
- API section MUST match actual src/index.ts exports — never invent
|
||||
- For cli packages: document CLI binary name, how it is invoked
|
||||
- For lib packages: document exported types and functions
|
||||
- Internal structure: list actual files in src/
|
||||
|
||||
### Step 4: Verify
|
||||
- All relative links work
|
||||
- Package names match package.json
|
||||
- No references to removed/renamed packages
|
||||
- bun run build still passes
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only document what src/index.ts actually exports
|
||||
- Root README summarizes, package READMEs go into detail
|
||||
- Verify CLI examples against actual commands
|
||||
- Preserve existing good prose when updating
|
||||
- English for all README content
|
||||
+5
-2
@@ -9,11 +9,14 @@
|
||||
"@changesets/changelog-github": "^0.7.0",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"bun-types": "^1.3.14",
|
||||
"typescript": "^5.8.0"
|
||||
"typescript": "^5.8.0",
|
||||
"ulidx": "^2.4.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --build packages/json-cas packages/json-cas-fs",
|
||||
"test": "bun test",
|
||||
"check": "biome check .",
|
||||
"format": "biome format --write ."
|
||||
"format": "biome format --write .",
|
||||
"release": "changeset version && bun run build && changeset publish"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# @uncaged/cli-json-cas
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.5.3
|
||||
- @uncaged/json-cas-fs@0.5.3
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.3.0
|
||||
- @uncaged/json-cas-fs@0.3.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.2.0
|
||||
- @uncaged/json-cas-fs@0.2.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: replace workspace:^ with actual version numbers in published dependencies
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.1.3
|
||||
- @uncaged/json-cas-fs@0.1.3
|
||||
@@ -0,0 +1,98 @@
|
||||
# @uncaged/cli-json-cas
|
||||
|
||||
CLI tool for json-cas stores.
|
||||
|
||||
## Overview
|
||||
|
||||
`@uncaged/cli-json-cas` provides the `json-cas` command for managing a filesystem-backed store: bootstrap, schema registration, node CRUD, integrity checks, reference listing, and graph walks. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
|
||||
|
||||
## Installation
|
||||
|
||||
Published as an npm package with a binary entry:
|
||||
|
||||
```bash
|
||||
bun add -g @uncaged/cli-json-cas
|
||||
# or from the monorepo workspace:
|
||||
bun link
|
||||
```
|
||||
|
||||
**Binary name:** `json-cas` (points to `src/index.ts`, run with Bun).
|
||||
|
||||
In development:
|
||||
|
||||
```bash
|
||||
bun packages/cli-json-cas/src/index.ts <command> [args]
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```
|
||||
Usage: json-cas [--store <path>] [--json] <command> [args]
|
||||
```
|
||||
|
||||
### Global flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
|
||||
| `--json` | Compact JSON output for commands that print JSON |
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `init` | Create store directory and write bootstrap seed; prints meta hash |
|
||||
| `bootstrap` | Write meta-schema seed into existing store; prints hash |
|
||||
| `schema put <file.json>` | Register schema from file; prints type hash |
|
||||
| `schema get <type-hash>` | Print schema JSON |
|
||||
| `schema list` | List all schemas (`hash name`) |
|
||||
| `schema validate <hash>` | Validate node against its schema; prints `valid` / `invalid` |
|
||||
| `put <type-hash> <file.json>` | Store node; prints content hash |
|
||||
| `get <hash>` | Print full node as JSON |
|
||||
| `has <hash>` | Print `true` or `false` |
|
||||
| `verify <hash>` | Verify integrity; prints `ok` or `corrupted` |
|
||||
| `refs <hash>` | Print direct `cas_ref` targets (one per line) |
|
||||
| `walk <hash>` | BFS traversal; one hash per line |
|
||||
| `walk <hash> --format tree` | Tree-formatted traversal |
|
||||
| `hash <type-hash> <file.json>` | Compute hash without storing |
|
||||
| `cat <hash>` | Print node JSON |
|
||||
| `cat <hash> --payload` | Print payload only |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Initialize default store at ~/.uncaged/json-cas
|
||||
json-cas init
|
||||
|
||||
# Use a custom store path
|
||||
json-cas --store ./data/cas bootstrap
|
||||
|
||||
# Register a schema and store a payload
|
||||
json-cas schema put ./schemas/item.json
|
||||
# → prints type hash, e.g. 0123456789ABCD
|
||||
|
||||
json-cas put 0123456789ABCD ./payloads/item.json
|
||||
# → prints content hash
|
||||
|
||||
json-cas get <content-hash> --json
|
||||
json-cas verify <content-hash>
|
||||
json-cas walk <content-hash> --format tree
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.ts` | Argument parsing, command dispatch, and all CLI logic |
|
||||
|
||||
There is no separate `src/` module tree; the CLI is a single entry file. Tests (if present) are co-located under the package.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Override |
|
||||
|---------|---------|----------|
|
||||
| Store directory | `~/.uncaged/json-cas` | `--store <path>` |
|
||||
|
||||
No config file is read; all behavior is controlled via flags and command arguments.
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "@uncaged/cli-json-cas",
|
||||
"version": "0.1.1",
|
||||
"version": "0.5.3",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"json-cas": "./src/index.ts"
|
||||
"json-cas": "./src/index.ts",
|
||||
"ucas": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "workspace:^",
|
||||
"@uncaged/json-cas-fs": "workspace:^"
|
||||
"@uncaged/json-cas": "^0.5.3",
|
||||
"@uncaged/json-cas-fs": "^0.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||
const entrypoint = resolve(import.meta.dir, "index.ts");
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
storePath?: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const finalArgs = storePath
|
||||
? ["bun", entrypoint, "--store", storePath, ...args]
|
||||
: ["bun", entrypoint, ...args];
|
||||
const proc = Bun.spawn(finalArgs, {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return { stdout, stderr, exitCode };
|
||||
}
|
||||
|
||||
describe("ucas command alias", () => {
|
||||
test("T1: ucas bin entry exists in package.json", async () => {
|
||||
const pkg = await Bun.file(pkgPath).json();
|
||||
expect(pkg.bin.ucas).toBe("./src/index.ts");
|
||||
});
|
||||
|
||||
test("T2: json-cas bin entry is preserved in package.json", async () => {
|
||||
const pkg = await Bun.file(pkgPath).json();
|
||||
expect(pkg.bin["json-cas"]).toBe("./src/index.ts");
|
||||
});
|
||||
|
||||
test("T3: ucas command is executable and shows help", async () => {
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("T4: both commands point to the same entrypoint", async () => {
|
||||
const pkg = await Bun.file(pkgPath).json();
|
||||
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- @ Alias Resolution Tests ----
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
testDir = join(
|
||||
tmpdir(),
|
||||
`json-cas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
cliPath = join(import.meta.dir, "index.ts");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test directory
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Run CLI command and return stdout, stderr, and exit code
|
||||
*/
|
||||
async function runCliAlias(...args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", "run", cliPath, "--store", storePath, ...args],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: proc.exitCode ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("@ Alias Resolution - schema get", () => {
|
||||
test("ucas schema get @string should work", async () => {
|
||||
await runCliAlias("init"); // Initialize store
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||
"schema",
|
||||
"get",
|
||||
"@string",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "string" });
|
||||
});
|
||||
|
||||
test("ucas schema get @number should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@number");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "number" });
|
||||
});
|
||||
|
||||
test("ucas schema get @object should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@object");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "object" });
|
||||
});
|
||||
|
||||
test("ucas schema get @array should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@array");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "array" });
|
||||
});
|
||||
|
||||
test("ucas schema get @bool should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@bool");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toEqual({ type: "boolean" });
|
||||
});
|
||||
|
||||
test("ucas schema get @schema should work", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias("schema", "get", "@schema");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const schema = JSON.parse(stdout);
|
||||
expect(schema).toHaveProperty("type", "object");
|
||||
expect(schema).toHaveProperty(
|
||||
"description",
|
||||
"json-cas JSON Schema meta-schema",
|
||||
);
|
||||
});
|
||||
|
||||
test("ucas schema get @invalid should fail gracefully", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const { stderr, exitCode } = await runCliAlias("schema", "get", "@invalid");
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Schema not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@ Alias Resolution - put", () => {
|
||||
test("ucas put @string <file> should resolve alias", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify("hello world"));
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||
"put",
|
||||
"@string",
|
||||
payloadFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
// Should output a valid hash (13 chars)
|
||||
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("ucas put @number <file> should resolve alias", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
writeFileSync(payloadFile, "42");
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias(
|
||||
"put",
|
||||
"@number",
|
||||
payloadFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("ucas put @object <file> should resolve alias", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ foo: "bar" }));
|
||||
|
||||
const { stdout, exitCode } = await runCliAlias(
|
||||
"put",
|
||||
"@object",
|
||||
payloadFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("ucas put @invalid <file> should fail", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
writeFileSync(payloadFile, "{}");
|
||||
|
||||
const { stderr, exitCode } = await runCliAlias(
|
||||
"put",
|
||||
"@invalid",
|
||||
payloadFile,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@ Alias Resolution - hash", () => {
|
||||
test("ucas hash @string <file> should compute hash without storing", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify("test"));
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCliAlias(
|
||||
"hash",
|
||||
"@string",
|
||||
payloadFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ucas render command", () => {
|
||||
test("R1: render requires hash argument", async () => {
|
||||
const { exitCode, stderr } = await runCli(["render"]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Usage");
|
||||
});
|
||||
|
||||
test("R2: render with missing hash shows error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
const { exitCode, stdout } = await runCli(
|
||||
["render", "ZZZZZZZZZZZZZ"],
|
||||
tmpStore,
|
||||
);
|
||||
// Missing hash renders as cas: reference
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("cas:ZZZZZZZZZZZZZ");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("R3: render with invalid numeric flag fails", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["render", "AAAAAAAAAAAAA", "--resolution", "invalid"],
|
||||
tmpStore,
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("valid number");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,23 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
|
||||
import {
|
||||
bootstrap,
|
||||
CasNodeNotFoundError,
|
||||
computeHash,
|
||||
createVariableStore,
|
||||
gc,
|
||||
getSchema,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
putSchema,
|
||||
refs,
|
||||
render,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
validate,
|
||||
verify,
|
||||
walk,
|
||||
@@ -18,10 +26,20 @@ import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
// ---- Argument parsing ----
|
||||
|
||||
type Flags = Record<string, string | boolean>;
|
||||
type Flags = Record<string, string | boolean | string[]>;
|
||||
|
||||
/** Flags that consume the next token as their value. All others are boolean. */
|
||||
const VALUE_FLAGS = new Set(["store", "format"]);
|
||||
const VALUE_FLAGS = new Set([
|
||||
"store",
|
||||
"format",
|
||||
"var-db",
|
||||
"tag",
|
||||
"schema",
|
||||
"resolution",
|
||||
"decay",
|
||||
"epsilon",
|
||||
"inline",
|
||||
]);
|
||||
|
||||
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
const flags: Flags = {};
|
||||
@@ -34,7 +52,19 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
if (VALUE_FLAGS.has(key)) {
|
||||
const next = argv[i + 1];
|
||||
if (next !== undefined && !next.startsWith("--")) {
|
||||
flags[key] = next;
|
||||
// Handle repeatable flags (like --tag)
|
||||
if (key === "tag") {
|
||||
const existing = flags[key];
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(next);
|
||||
} else if (typeof existing === "string") {
|
||||
flags[key] = [existing, next];
|
||||
} else {
|
||||
flags[key] = [next];
|
||||
}
|
||||
} else {
|
||||
flags[key] = next;
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
flags[key] = true;
|
||||
@@ -53,9 +83,14 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
const { flags, positional } = parseArgs(process.argv.slice(2));
|
||||
|
||||
const defaultStorePath = join(homedir(), ".uncaged", "json-cas");
|
||||
const storePath = typeof flags.store === "string" ? flags.store : defaultStorePath;
|
||||
const storePath =
|
||||
typeof flags.store === "string" ? flags.store : defaultStorePath;
|
||||
const compact = flags.json === true;
|
||||
|
||||
const defaultVarDbPath = join(storePath, "variables.db");
|
||||
const varDbPath =
|
||||
typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath;
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function out(data: unknown): void {
|
||||
@@ -79,20 +114,127 @@ function openStore(): Store {
|
||||
return createFsStore(resolve(storePath));
|
||||
}
|
||||
|
||||
function openVarStore(): VariableStore {
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
return createVariableStore(resolve(varDbPath), store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a type-hash, handling @ aliases
|
||||
* If the input starts with @, resolve it via bootstrap
|
||||
* Otherwise, return the hash as-is
|
||||
*/
|
||||
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
|
||||
if (typeHashOrAlias.startsWith("@")) {
|
||||
const store = openStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const resolvedHash = builtinSchemas[typeHashOrAlias];
|
||||
if (!resolvedHash) {
|
||||
die(`Schema not found: ${typeHashOrAlias}`);
|
||||
}
|
||||
return resolvedHash;
|
||||
}
|
||||
return typeHashOrAlias;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Variable schema's CAS hash
|
||||
* This is the type hash used in JSON envelopes
|
||||
*/
|
||||
async function getVariableSchemaHash(): Promise<Hash> {
|
||||
const store = openStore();
|
||||
|
||||
// Define the Variable JSON Schema (updated for new model with composite key)
|
||||
const variableSchema: JSONSchema = {
|
||||
title: "Variable",
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
schema: { type: "string" },
|
||||
value: { type: "string" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
tags: { type: "object" },
|
||||
labels: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: [
|
||||
"name",
|
||||
"schema",
|
||||
"value",
|
||||
"created",
|
||||
"updated",
|
||||
"tags",
|
||||
"labels",
|
||||
],
|
||||
};
|
||||
|
||||
// Compute hash or retrieve from store
|
||||
const hash = await putSchema(store, variableSchema);
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap Variable output in JSON envelope
|
||||
*/
|
||||
async function wrapVariableEnvelope(
|
||||
variable: unknown,
|
||||
): Promise<{ type: Hash; value: unknown }> {
|
||||
const typeHash = await getVariableSchemaHash();
|
||||
return {
|
||||
type: typeHash,
|
||||
value: variable,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tag/label arguments
|
||||
* Returns: { tags: Record<string, string>, labels: string[], deleteNames: string[] }
|
||||
*/
|
||||
function parseTagsLabels(args: string[]): {
|
||||
tags: Record<string, string>;
|
||||
labels: string[];
|
||||
deleteNames: string[];
|
||||
} {
|
||||
const tags: Record<string, string> = {};
|
||||
const labels: string[] = [];
|
||||
const deleteNames: string[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith(":")) {
|
||||
// Deletion syntax: :name
|
||||
deleteNames.push(arg.slice(1));
|
||||
} else if (arg.includes(":")) {
|
||||
// Tag: key:value (split on first colon)
|
||||
const colonIdx = arg.indexOf(":");
|
||||
const key = arg.slice(0, colonIdx);
|
||||
const value = arg.slice(colonIdx + 1);
|
||||
tags[key] = value;
|
||||
} else {
|
||||
// Label: bare identifier
|
||||
labels.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return { tags, labels, deleteNames };
|
||||
}
|
||||
|
||||
// ---- Commands ----
|
||||
|
||||
async function cmdInit(): Promise<void> {
|
||||
const dir = resolve(storePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const store = createFsStore(dir);
|
||||
const hash = await bootstrap(store);
|
||||
console.log(hash);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
console.log(metaHash);
|
||||
}
|
||||
|
||||
async function cmdBootstrap(): Promise<void> {
|
||||
const store = openStore();
|
||||
const hash = await bootstrap(store);
|
||||
console.log(hash);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
console.log(metaHash);
|
||||
}
|
||||
|
||||
async function cmdSchemaPut(args: string[]): Promise<void> {
|
||||
@@ -105,21 +247,24 @@ async function cmdSchemaPut(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdSchemaGet(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas schema get <type-hash>");
|
||||
const hashOrAlias = args[0];
|
||||
if (!hashOrAlias) die("Usage: json-cas schema get <type-hash>");
|
||||
const hash = await resolveTypeHash(hashOrAlias);
|
||||
const store = openStore();
|
||||
const schema = getSchema(store, hash);
|
||||
if (schema === null) die(`Schema not found: ${hash}`);
|
||||
if (schema === null) die(`Schema not found: ${hashOrAlias}`);
|
||||
out(schema);
|
||||
}
|
||||
|
||||
async function cmdSchemaList(): Promise<void> {
|
||||
const store = openStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
for (const hash of store.list()) {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) throw new Error("Meta-schema not found");
|
||||
for (const hash of store.listByType(metaHash)) {
|
||||
if (hash === metaHash) continue;
|
||||
const node = store.get(hash);
|
||||
if (node !== null && node.type === metaHash) {
|
||||
if (node !== null) {
|
||||
const schema = node.payload as JSONSchema;
|
||||
const name =
|
||||
(schema.title as string | undefined) ??
|
||||
@@ -141,9 +286,11 @@ async function cmdSchemaValidate(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdPut(args: string[]): Promise<void> {
|
||||
const typeHash = args[0];
|
||||
const typeHashOrAlias = args[0];
|
||||
const file = args[1];
|
||||
if (!typeHash || !file) die("Usage: json-cas put <type-hash> <file.json>");
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas put <type-hash> <file.json>");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
const payload = readJsonFile(file);
|
||||
const store = openStore();
|
||||
const hash = await store.put(typeHash, payload);
|
||||
@@ -176,13 +323,6 @@ async function cmdVerify(args: string[]): Promise<void> {
|
||||
console.log(ok ? "ok" : "corrupted");
|
||||
}
|
||||
|
||||
async function cmdList(): Promise<void> {
|
||||
const store = openStore();
|
||||
for (const hash of store.list()) {
|
||||
console.log(hash);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdRefs(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) die("Usage: json-cas refs <hash>");
|
||||
@@ -235,14 +375,63 @@ async function cmdWalk(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function cmdHash(args: string[]): Promise<void> {
|
||||
const typeHash = args[0];
|
||||
const typeHashOrAlias = args[0];
|
||||
const file = args[1];
|
||||
if (!typeHash || !file) die("Usage: json-cas hash <type-hash> <file.json>");
|
||||
if (!typeHashOrAlias || !file)
|
||||
die("Usage: json-cas hash <type-hash> <file.json>");
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias);
|
||||
const payload = readJsonFile(file);
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
console.log(hash);
|
||||
}
|
||||
|
||||
async function cmdRender(args: string[]): Promise<void> {
|
||||
const hash = args[0];
|
||||
if (!hash) {
|
||||
die(
|
||||
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]",
|
||||
);
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
|
||||
// Parse numeric options
|
||||
const resolution =
|
||||
typeof flags.resolution === "string"
|
||||
? Number.parseFloat(flags.resolution)
|
||||
: undefined;
|
||||
const decay =
|
||||
typeof flags.decay === "string"
|
||||
? Number.parseFloat(flags.decay)
|
||||
: undefined;
|
||||
const epsilon =
|
||||
typeof flags.epsilon === "string"
|
||||
? Number.parseFloat(flags.epsilon)
|
||||
: undefined;
|
||||
|
||||
// Validate numeric values
|
||||
if (resolution !== undefined && Number.isNaN(resolution)) {
|
||||
die("--resolution must be a valid number");
|
||||
}
|
||||
if (decay !== undefined && Number.isNaN(decay)) {
|
||||
die("--decay must be a valid number");
|
||||
}
|
||||
if (epsilon !== undefined && Number.isNaN(epsilon)) {
|
||||
die("--epsilon must be a valid number");
|
||||
}
|
||||
|
||||
try {
|
||||
const 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>");
|
||||
@@ -256,6 +445,371 @@ async function cmdCat(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarSet(args: string[]): Promise<void> {
|
||||
const name = args[0];
|
||||
const value = args[1];
|
||||
const tagFlags = flags.tag;
|
||||
|
||||
if (!name || !value) {
|
||||
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
// Parse tags/labels from --tag flags
|
||||
const tagArgs = Array.isArray(tagFlags)
|
||||
? tagFlags
|
||||
: typeof tagFlags === "string"
|
||||
? [tagFlags]
|
||||
: [];
|
||||
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
|
||||
|
||||
// Check for conflicts in initial tags/labels
|
||||
if (deleteNames.length > 0) {
|
||||
die("Error: Cannot use deletion syntax (:name) in var set");
|
||||
}
|
||||
|
||||
// If --tag flags are provided at all, always pass options to replace tags/labels
|
||||
// If no --tag flags, pass undefined to preserve existing tags/labels
|
||||
const options =
|
||||
tagArgs.length > 0
|
||||
? {
|
||||
tags: Object.keys(tags).length > 0 ? tags : {},
|
||||
labels: labels.length > 0 ? labels : [],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const variable = varStore.set(name, value, options);
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof InvalidVariableNameError ||
|
||||
e instanceof CasNodeNotFoundError ||
|
||||
e instanceof TagLabelConflictError
|
||||
) {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarGet(args: string[]): Promise<void> {
|
||||
const name = args[0];
|
||||
const schema = flags.schema as string | undefined;
|
||||
|
||||
if (!name || !schema) {
|
||||
die("Usage: json-cas var get <name> --schema <hash>");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
const variable = varStore.get(name, schema);
|
||||
if (variable === null) {
|
||||
die(`Error: Variable not found: name=${name}, schema=${schema}`);
|
||||
}
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarDelete(args: string[]): Promise<void> {
|
||||
const name = args[0];
|
||||
const schema = flags.schema as string | undefined;
|
||||
|
||||
if (!name) {
|
||||
die("Usage: json-cas var delete <name> [--schema <hash>]");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
if (schema !== undefined) {
|
||||
// Precise deletion: remove specific (name, schema) variant
|
||||
const variable = varStore.remove(name, schema);
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
} else {
|
||||
// Batch deletion: remove all variants for this name
|
||||
const variables = varStore.remove(name);
|
||||
const envelope = await wrapVariableEnvelope(variables);
|
||||
out(envelope);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof VariableNotFoundError) {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarTag(args: string[]): Promise<void> {
|
||||
const name = args[0];
|
||||
const schema = flags.schema as string | undefined;
|
||||
|
||||
if (!name || !schema) {
|
||||
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
|
||||
}
|
||||
|
||||
const tagArgs = args.slice(1);
|
||||
if (tagArgs.length === 0) {
|
||||
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
|
||||
}
|
||||
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
|
||||
|
||||
const variable = varStore.tag(name, schema, {
|
||||
add: Object.keys(tags).length > 0 ? tags : undefined,
|
||||
addLabels: labels.length > 0 ? labels : undefined,
|
||||
delete: deleteNames.length > 0 ? deleteNames : undefined,
|
||||
});
|
||||
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof VariableNotFoundError ||
|
||||
e instanceof TagLabelConflictError ||
|
||||
e instanceof InvalidTagFormatError
|
||||
) {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarList(args: string[]): Promise<void> {
|
||||
const namePrefix = args[0] ?? "";
|
||||
const schema = flags.schema as string | undefined;
|
||||
const tagFlags = flags.tag;
|
||||
|
||||
const varStore = openVarStore();
|
||||
|
||||
try {
|
||||
// Parse tags/labels from --tag flags
|
||||
const tagArgs = Array.isArray(tagFlags)
|
||||
? tagFlags
|
||||
: typeof tagFlags === "string"
|
||||
? [tagFlags]
|
||||
: [];
|
||||
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
|
||||
|
||||
// Check for invalid deletion syntax in filters
|
||||
if (deleteNames.length > 0) {
|
||||
die("Error: Cannot use deletion syntax (:name) in var list filters");
|
||||
}
|
||||
|
||||
const variables = varStore.list({
|
||||
namePrefix,
|
||||
schema,
|
||||
tags: Object.keys(tags).length > 0 ? tags : undefined,
|
||||
labels: labels.length > 0 ? labels : undefined,
|
||||
});
|
||||
const envelope = await wrapVariableEnvelope(variables);
|
||||
out(envelope);
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidVariableNameError) {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateSet(args: string[]): Promise<void> {
|
||||
const schemaHash = args[0];
|
||||
const inlineFlag = flags.inline;
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
// Validate schema hash exists in CAS
|
||||
if (!store.has(schemaHash)) {
|
||||
die(`Error: Schema hash not found in CAS: ${schemaHash}`);
|
||||
}
|
||||
|
||||
// Determine content source
|
||||
let content: string;
|
||||
|
||||
if (typeof inlineFlag === "string") {
|
||||
// --inline mode
|
||||
const fileArg = args[1];
|
||||
if (fileArg !== undefined && !fileArg.startsWith("--")) {
|
||||
die("Error: Cannot specify both file and --inline");
|
||||
}
|
||||
content = inlineFlag;
|
||||
} else if (inlineFlag === true) {
|
||||
// --inline flag present but no value
|
||||
const contentArg = args[1];
|
||||
if (!contentArg) {
|
||||
die(
|
||||
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
|
||||
);
|
||||
}
|
||||
content = contentArg;
|
||||
} else {
|
||||
// File mode
|
||||
const file = args[1];
|
||||
if (!file) {
|
||||
die(
|
||||
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
|
||||
);
|
||||
}
|
||||
if (!existsSync(file)) {
|
||||
die(`Error: File not found: ${file}`);
|
||||
}
|
||||
content = readFileSync(file, "utf-8");
|
||||
}
|
||||
|
||||
// Store content in CAS under @string schema
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
const contentHash = await store.put(stringHash, content);
|
||||
|
||||
// Create variable binding: @ucas/template/text/<schema-hash>
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
varStore.set(varName, contentHash);
|
||||
|
||||
out({
|
||||
schemaHash,
|
||||
contentHash,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CasNodeNotFoundError) {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateGet(args: string[]): Promise<void> {
|
||||
const schemaHash = args[0];
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template get <schema-hash>");
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
const variable = varStore.get(varName, stringHash);
|
||||
|
||||
if (variable === null) {
|
||||
die(`Error: Template not found for schema: ${schemaHash}`);
|
||||
}
|
||||
|
||||
// Get the content from CAS
|
||||
const node = store.get(variable.value);
|
||||
if (node === null) {
|
||||
die(`Error: Content not found in CAS: ${variable.value}`);
|
||||
}
|
||||
|
||||
// Output raw text (not JSON)
|
||||
process.stdout.write(node.payload as string);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateList(_args: string[]): Promise<void> {
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
const variables = varStore.list({
|
||||
namePrefix: "@ucas/template/text/",
|
||||
schema: stringHash,
|
||||
});
|
||||
|
||||
const templates = variables.map((v) => {
|
||||
const schemaHash = v.name.replace("@ucas/template/text/", "");
|
||||
|
||||
// Get content for preview
|
||||
const node = store.get(v.value);
|
||||
const content = (node?.payload as string | undefined) ?? "";
|
||||
|
||||
// Truncate preview to 80 chars
|
||||
const preview =
|
||||
content.length > 80 ? `${content.slice(0, 77)}...` : content;
|
||||
|
||||
return {
|
||||
schemaHash,
|
||||
preview,
|
||||
};
|
||||
});
|
||||
|
||||
out(templates);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateDelete(args: string[]): Promise<void> {
|
||||
const schemaHash = args[0];
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template delete <schema-hash>");
|
||||
}
|
||||
|
||||
const store = openStore();
|
||||
mkdirSync(resolve(storePath), { recursive: true });
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const varName = `@ucas/template/text/${schemaHash}`;
|
||||
const stringHash = await resolveTypeHash("@string");
|
||||
varStore.remove(varName, stringHash);
|
||||
|
||||
out({ deleted: true });
|
||||
} catch (e) {
|
||||
if (e instanceof VariableNotFoundError) {
|
||||
die(`Error: Template not found for schema: ${schemaHash}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdGc(_args: string[]): Promise<void> {
|
||||
const store = createFsStore(storePath);
|
||||
const varStore = createVariableStore(varDbPath, store);
|
||||
|
||||
try {
|
||||
const stats = gc(store, varStore);
|
||||
out(stats);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`\
|
||||
Usage: json-cas [--store <path>] [--json] <command> [args]
|
||||
@@ -271,15 +825,32 @@ Commands:
|
||||
get <hash> Print node as JSON
|
||||
has <hash> Print true/false
|
||||
verify <hash> Verify integrity, print ok/corrupted
|
||||
list List all hashes
|
||||
refs <hash> List direct cas_ref edges
|
||||
walk <hash> [--format tree] Recursive traversal
|
||||
hash <type-hash> <file.json> Compute hash without storing (dry run)
|
||||
render <hash> [options] Render node as YAML with resolution decay
|
||||
cat <hash> [--payload] Output node (--payload for payload only)
|
||||
var set <name> <hash> [--tag <tag>...] Create/update a variable
|
||||
var get <name> --schema <hash> Get a variable by name + schema
|
||||
var delete <name> [--schema <hash>] Delete variable(s)
|
||||
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables
|
||||
var tag <name> --schema <hash> <operations...> Modify tags/labels
|
||||
template set <schema-hash> <file> | --inline <text> Set template for schema
|
||||
template get <schema-hash> Get template content as raw text
|
||||
template list List all templates
|
||||
template delete <schema-hash> Delete template for schema
|
||||
gc Run garbage collection
|
||||
|
||||
Flags:
|
||||
--store <path> Store directory (default: ~/.uncaged/json-cas)
|
||||
--json Compact JSON output`);
|
||||
--store <path> Store directory (default: ~/.uncaged/json-cas)
|
||||
--var-db <path> Variable database path (default: <store>/variables.db)
|
||||
--json Compact JSON output
|
||||
--schema <hash> Schema hash filter for var get/delete/tag/list
|
||||
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
|
||||
--inline <text> Inline text content for template set
|
||||
--resolution <n> Initial resolution for render (default: 1.0)
|
||||
--decay <n> Decay factor for render (default: 0.5)
|
||||
--epsilon <n> Cutoff threshold for render (default: 0.01)`);
|
||||
}
|
||||
|
||||
// ---- Dispatch ----
|
||||
@@ -337,10 +908,6 @@ switch (cmd) {
|
||||
await cmdVerify(rest);
|
||||
break;
|
||||
|
||||
case "list":
|
||||
await cmdList();
|
||||
break;
|
||||
|
||||
case "refs":
|
||||
await cmdRefs(rest);
|
||||
break;
|
||||
@@ -353,10 +920,63 @@ switch (cmd) {
|
||||
await cmdHash(rest);
|
||||
break;
|
||||
|
||||
case "render":
|
||||
await cmdRender(rest);
|
||||
break;
|
||||
|
||||
case "cat":
|
||||
await cmdCat(rest);
|
||||
break;
|
||||
|
||||
case "var": {
|
||||
const [sub, ...subRest] = rest;
|
||||
switch (sub) {
|
||||
case "set":
|
||||
await cmdVarSet(subRest);
|
||||
break;
|
||||
case "get":
|
||||
await cmdVarGet(subRest);
|
||||
break;
|
||||
case "delete":
|
||||
await cmdVarDelete(subRest);
|
||||
break;
|
||||
case "tag":
|
||||
await cmdVarTag(subRest);
|
||||
break;
|
||||
case "list":
|
||||
await cmdVarList(subRest);
|
||||
break;
|
||||
default:
|
||||
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "template": {
|
||||
const [sub, ...subRest] = rest;
|
||||
switch (sub) {
|
||||
case "set":
|
||||
await cmdTemplateSet(subRest);
|
||||
break;
|
||||
case "get":
|
||||
await cmdTemplateGet(subRest);
|
||||
break;
|
||||
case "list":
|
||||
await cmdTemplateList(subRest);
|
||||
break;
|
||||
case "delete":
|
||||
await cmdTemplateDelete(subRest);
|
||||
break;
|
||||
default:
|
||||
die(`Unknown template subcommand: ${sub ?? "(none)"}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "gc":
|
||||
await cmdGc(rest);
|
||||
break;
|
||||
|
||||
default:
|
||||
die(`Unknown command: ${cmd}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
// ---- Test helpers ----
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let varDbPath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
testDir = join(
|
||||
tmpdir(),
|
||||
`json-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
cliPath = join(import.meta.dir, "index.ts");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test directory
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Run CLI command and return stdout, stderr, and exit code
|
||||
*/
|
||||
async function runCli(...args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--store",
|
||||
storePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
...args,
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: proc.exitCode ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bootstrap @string type hash
|
||||
*/
|
||||
async function getStringHash(store: Store): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
return builtinSchemas["@string"] ?? "";
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
describe("template set", () => {
|
||||
test("set template from file", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "Hello {{name}}!");
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
templateFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
expect(output.schemaHash).toBe(stringHash);
|
||||
});
|
||||
|
||||
test("set template with --inline flag", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
"Inline template content",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
expect(output.schemaHash).toBe(stringHash);
|
||||
});
|
||||
|
||||
test("update existing template (idempotent)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "Version 1");
|
||||
|
||||
// Set first time
|
||||
await runCli("template", "set", stringHash, templateFile);
|
||||
|
||||
// Update with new content
|
||||
writeFileSync(templateFile, "Version 2");
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
templateFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
|
||||
// Verify we can get the new version
|
||||
const { stdout: getOut } = await runCli("template", "get", stringHash);
|
||||
expect(getOut).toBe("Version 2");
|
||||
});
|
||||
|
||||
test("error when file not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"/nonexistent/file.txt",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
});
|
||||
|
||||
test("error when schema hash invalid", async () => {
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "content");
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
"INVALID_HASH",
|
||||
templateFile,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
});
|
||||
|
||||
test("error when both file and --inline provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
writeFileSync(templateFile, "content");
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
templateFile,
|
||||
"--inline",
|
||||
"inline content",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
});
|
||||
|
||||
test("support multi-line templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const multilineContent = "Line 1\nLine 2\nLine 3";
|
||||
const { exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
multilineContent,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify content
|
||||
const { stdout: getOut } = await runCli("template", "get", stringHash);
|
||||
expect(getOut).toBe(multilineContent);
|
||||
});
|
||||
|
||||
test("support empty templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("contentHash");
|
||||
});
|
||||
|
||||
test("error when neither file nor --inline provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "set", stringHash);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("support templates with special characters", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const specialContent = "Template with {{var}} and $env and @ref";
|
||||
const { exitCode } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
specialContent,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify content preserved
|
||||
const { stdout: getOut } = await runCli("template", "get", stringHash);
|
||||
expect(getOut).toBe(specialContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template get", () => {
|
||||
test("retrieve template as raw text", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Hello {{name}}!";
|
||||
await runCli("template", "set", stringHash, "--inline", content);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"get",
|
||||
stringHash,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout).toBe(content);
|
||||
});
|
||||
|
||||
test("error when template not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "get", stringHash);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
expect(stderr).toContain("not found");
|
||||
});
|
||||
|
||||
test("preserve exact whitespace", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Note: runCli helper trims stdout, so we test with content that doesn't have leading/trailing whitespace
|
||||
// The actual CLI preserves whitespace correctly
|
||||
const content = "spaces\n\ttabs\t\nmixed";
|
||||
await runCli("template", "set", stringHash, "--inline", content);
|
||||
|
||||
const { stdout } = await runCli("template", "get", stringHash);
|
||||
|
||||
expect(stdout).toBe(content);
|
||||
});
|
||||
|
||||
test("support multi-line templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Note: runCli helper trims stdout, so trailing newline will be removed
|
||||
const multiline = "Line 1\nLine 2\nLine 3";
|
||||
await runCli("template", "set", stringHash, "--inline", multiline);
|
||||
|
||||
const { stdout } = await runCli("template", "get", stringHash);
|
||||
|
||||
expect(stdout).toBe(multiline);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template list", () => {
|
||||
test("list all templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create multiple templates
|
||||
await runCli("template", "set", stringHash, "--inline", "Template 1");
|
||||
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
|
||||
|
||||
const { stdout, exitCode } = await runCli("template", "list");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(Array.isArray(output)).toBe(true);
|
||||
expect(output.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Check structure
|
||||
const item = output[0];
|
||||
expect(item).toHaveProperty("schemaHash");
|
||||
expect(item).toHaveProperty("preview");
|
||||
});
|
||||
|
||||
test("preview truncation for long content", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const longContent = "a".repeat(200);
|
||||
await runCli("template", "set", stringHash, "--inline", longContent);
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
const output = JSON.parse(stdout) as Array<{
|
||||
schemaHash: string;
|
||||
preview: string;
|
||||
}>;
|
||||
const item = output.find((i) => i.schemaHash === stringHash);
|
||||
expect(item).toBeDefined();
|
||||
if (item) {
|
||||
expect(item.preview.length).toBeLessThan(longContent.length);
|
||||
expect(item.preview).toContain("...");
|
||||
}
|
||||
});
|
||||
|
||||
test("empty list when no templates", async () => {
|
||||
const { stdout, exitCode } = await runCli("template", "list");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(Array.isArray(output)).toBe(true);
|
||||
expect(output.length).toBe(0);
|
||||
});
|
||||
|
||||
test("exclude non-template variables", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create a template
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
// Create a regular variable (not under @ucas/template/text/)
|
||||
const hash = await store.put(stringHash, "regular var content");
|
||||
await runCli("var", "set", "regular/var", hash);
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
// Should only contain template variables
|
||||
for (const item of output) {
|
||||
expect(item.schemaHash).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("output JSON array format", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Test");
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
// Should be valid JSON
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(Array.isArray(output)).toBe(true);
|
||||
});
|
||||
|
||||
test("preview shows beginning of content", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Start of template...";
|
||||
await runCli("template", "set", stringHash, "--inline", content);
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
|
||||
const output = JSON.parse(stdout) as Array<{
|
||||
schemaHash: string;
|
||||
preview: string;
|
||||
}>;
|
||||
const item = output.find((i) => i.schemaHash === stringHash);
|
||||
if (item) {
|
||||
expect(item.preview).toContain("Start");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("template delete", () => {
|
||||
test("delete template variable binding", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output).toHaveProperty("deleted");
|
||||
expect(output.deleted).toBe(true);
|
||||
|
||||
// Verify template is gone
|
||||
const { exitCode: getExitCode } = await runCli(
|
||||
"template",
|
||||
"get",
|
||||
stringHash,
|
||||
);
|
||||
expect(getExitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("error when template not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "delete", stringHash);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error:");
|
||||
expect(stderr).toContain("not found");
|
||||
});
|
||||
|
||||
test("deletion does not affect other templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create two templates
|
||||
await runCli("template", "set", stringHash, "--inline", "Template 1");
|
||||
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
|
||||
|
||||
// Delete first template
|
||||
await runCli("template", "delete", stringHash);
|
||||
|
||||
// Verify second still exists
|
||||
const { stdout } = await runCli("template", "list");
|
||||
const output = JSON.parse(stdout) as Array<{
|
||||
schemaHash: string;
|
||||
preview: string;
|
||||
}>;
|
||||
|
||||
// Should not find deleted template
|
||||
const deleted = output.find((i) => i.schemaHash === stringHash);
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
test("CAS content remains after variable deletion", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Content");
|
||||
|
||||
// Get the content hash before deletion
|
||||
const { stdout: setOut } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
"Content",
|
||||
);
|
||||
const { contentHash } = JSON.parse(setOut);
|
||||
|
||||
// Delete the template variable
|
||||
await runCli("template", "delete", stringHash);
|
||||
|
||||
// Verify CAS node still exists
|
||||
const { exitCode: hasExitCode } = await runCli("has", contentHash);
|
||||
expect(hasExitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("deletion is non-idempotent (second delete fails)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
// First deletion succeeds
|
||||
const { exitCode: firstExit } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
expect(firstExit).toBe(0);
|
||||
|
||||
// Second deletion fails
|
||||
const { exitCode: secondExit } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
expect(secondExit).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template integration", () => {
|
||||
test("end-to-end workflow: set→get→list→delete", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Integration test template";
|
||||
|
||||
// Set
|
||||
const { exitCode: setExit } = await runCli(
|
||||
"template",
|
||||
"set",
|
||||
stringHash,
|
||||
"--inline",
|
||||
content,
|
||||
);
|
||||
expect(setExit).toBe(0);
|
||||
|
||||
// Get
|
||||
const { stdout: getOut, exitCode: getExit } = await runCli(
|
||||
"template",
|
||||
"get",
|
||||
stringHash,
|
||||
);
|
||||
expect(getExit).toBe(0);
|
||||
expect(getOut).toBe(content);
|
||||
|
||||
// List
|
||||
const { stdout: listOut, exitCode: listExit } = await runCli(
|
||||
"template",
|
||||
"list",
|
||||
);
|
||||
expect(listExit).toBe(0);
|
||||
const listData = JSON.parse(listOut);
|
||||
expect(listData.length).toBeGreaterThan(0);
|
||||
|
||||
// Delete
|
||||
const { exitCode: delExit } = await runCli(
|
||||
"template",
|
||||
"delete",
|
||||
stringHash,
|
||||
);
|
||||
expect(delExit).toBe(0);
|
||||
|
||||
// Verify deleted
|
||||
const { exitCode: finalGet } = await runCli("template", "get", stringHash);
|
||||
expect(finalGet).toBe(1);
|
||||
});
|
||||
|
||||
test("templates compatible with generic var commands", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Set via template command
|
||||
await runCli("template", "set", stringHash, "--inline", "Content");
|
||||
|
||||
// List via var command - should see template variable
|
||||
const { stdout } = await runCli("var", "list", "@ucas/template/text/");
|
||||
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("multiple templates for different schemas", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create templates for different schemas
|
||||
await runCli("template", "set", stringHash, "--inline", "Template 1");
|
||||
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
|
||||
await runCli("template", "set", "SCHEMA_HASH_3", "--inline", "Template 3");
|
||||
|
||||
// List should show all
|
||||
const { stdout } = await runCli("template", "list");
|
||||
const output = JSON.parse(stdout);
|
||||
expect(output.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template error handling", () => {
|
||||
test("unknown template subcommand", async () => {
|
||||
const { stderr, exitCode } = await runCli("template", "unknown");
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Unknown");
|
||||
});
|
||||
|
||||
test("missing schema hash argument", async () => {
|
||||
const { stderr, exitCode } = await runCli("template", "set");
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Usage:");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
|
||||
# @uncaged/json-cas-fs
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feat: add oneOf support to meta-schema validation
|
||||
|
||||
Added `oneOf` to `ALLOWED_SCHEMA_KEYS` and corresponding validation logic
|
||||
in `isValidSchema`. This enables workflow frontmatter schemas that use
|
||||
`oneOf` discriminated unions for multi-exit role definitions.
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.5.3
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.3.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.2.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.1.3
|
||||
@@ -0,0 +1,67 @@
|
||||
# @uncaged/json-cas-fs
|
||||
|
||||
Filesystem-backed CAS store.
|
||||
|
||||
## Overview
|
||||
|
||||
`@uncaged/json-cas-fs` implements a persistent `Store` on disk. Each node is stored as `<hash>.bin` (CBOR-encoded `CasNode`). A `_index/` directory maps type hashes to content hashes for `listByType`. Stores support bootstrap via the same `BOOTSTRAP_STORE` symbol as the in-memory implementation.
|
||||
|
||||
Depends on `@uncaged/json-cas` for hashing, CBOR encoding, and types.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `cborg`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @uncaged/json-cas-fs
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Exported from `src/index.ts`:
|
||||
|
||||
```typescript
|
||||
function createFsStore(dir: string): BootstrapCapableStore;
|
||||
```
|
||||
|
||||
Returns a `BootstrapCapableStore` from `@uncaged/json-cas`. The store loads existing `.bin` files on open and migrates or builds the type index on first use.
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
const store = createFsStore("./my-cas-store");
|
||||
await bootstrap(store);
|
||||
|
||||
const typeHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const hash = await store.put(typeHash, { id: "item-1" });
|
||||
console.log(store.has(hash)); // true after restart if same dir
|
||||
```
|
||||
|
||||
### On-disk layout
|
||||
|
||||
```
|
||||
my-cas-store/
|
||||
├── <hash>.bin # CBOR CasNode
|
||||
├── _index/
|
||||
│ └── <typeHash> # newline-separated content hashes
|
||||
└── ...
|
||||
```
|
||||
|
||||
Writes use atomic rename (`<hash>.tmp` → `<hash>.bin`).
|
||||
|
||||
## Internal Structure
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `store.ts` | `createFsStore`, load/save nodes and type index |
|
||||
| `index.ts` | Public export |
|
||||
| `store.test.ts` | Filesystem store tests |
|
||||
@@ -1,16 +1,25 @@
|
||||
{
|
||||
"name": "@uncaged/json-cas-fs",
|
||||
"version": "0.1.1",
|
||||
"version": "0.5.3",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "workspace:^",
|
||||
"@uncaged/json-cas": "^0.5.3",
|
||||
"cborg": "^4.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasNode } from "@uncaged/json-cas";
|
||||
@@ -30,20 +30,21 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
|
||||
test("store opens against an existing empty dir", () => {
|
||||
const store = createFsStore(dir);
|
||||
expect(store.list()).toEqual([]);
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
});
|
||||
|
||||
test("store creates the directory on first put", async () => {
|
||||
const nested = join(dir, "sub", "store");
|
||||
const store = createFsStore(nested);
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
await store.put(typeHash, { x: 1 });
|
||||
expect(store.list()).toHaveLength(1);
|
||||
const hash = await store.put(typeHash, { x: 1 });
|
||||
expect(store.has(hash)).toBe(true);
|
||||
});
|
||||
|
||||
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const hash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
@@ -57,8 +58,8 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
const h1 = await bootstrap(store);
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toBe(h2);
|
||||
expect(store.list()).toHaveLength(1);
|
||||
expect(h1).toEqual(h2);
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +85,7 @@ describe("createFsStore – persistence round-trip", () => {
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.has(h1)).toBe(true);
|
||||
expect(store2.has(h2)).toBe(true);
|
||||
expect(store2.list()).toHaveLength(2);
|
||||
expect(store2.listByType(typeHash)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("round-trip preserves type, payload, and timestamp", async () => {
|
||||
@@ -104,7 +105,8 @@ describe("createFsStore – persistence round-trip", () => {
|
||||
|
||||
test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const hash = await bootstrap(store1);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const node = store2.get(hash) as CasNode;
|
||||
@@ -125,7 +127,7 @@ describe("createFsStore – persistence round-trip", () => {
|
||||
const ts2 = store2.get(hash)?.timestamp;
|
||||
|
||||
expect(ts1).toBe(ts2);
|
||||
expect(store2.list()).toHaveLength(1);
|
||||
expect(store2.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +153,7 @@ describe("createFsStore – has and list", () => {
|
||||
expect(store.has(hash)).toBe(true);
|
||||
});
|
||||
|
||||
test("list returns all stored hashes", async () => {
|
||||
test("listByType returns all stored hashes for a type", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
|
||||
@@ -159,16 +161,16 @@ describe("createFsStore – has and list", () => {
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
const h3 = await store.put(typeHash, { a: 3 });
|
||||
|
||||
const all = store.list();
|
||||
const all = store.listByType(typeHash);
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all).toContain(h1);
|
||||
expect(all).toContain(h2);
|
||||
expect(all).toContain(h3);
|
||||
});
|
||||
|
||||
test("list returns empty array on fresh store", () => {
|
||||
test("listByType returns empty array on fresh store", () => {
|
||||
const store = createFsStore(dir);
|
||||
expect(store.list()).toEqual([]);
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
});
|
||||
|
||||
test("get returns null for unknown hash", () => {
|
||||
@@ -177,6 +179,88 @@ describe("createFsStore – has and list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// listByType and index migration
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createFsStore – listByType", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = makeTmpDir();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("returns empty array for unknown type", () => {
|
||||
const store = createFsStore(dir);
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns all hashes for the given type", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
const otherType = await computeSelfHash({ name: "other" });
|
||||
|
||||
const h1 = await store.put(typeHash, { a: 1 });
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
await store.put(otherType, { b: 1 });
|
||||
|
||||
const byType = store.listByType(typeHash);
|
||||
expect(byType).toHaveLength(2);
|
||||
expect(byType).toContain(h1);
|
||||
expect(byType).toContain(h2);
|
||||
});
|
||||
|
||||
test("listByType survives round-trip across store instances", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "persist-by-type" });
|
||||
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1.put(typeHash, { x: 1 });
|
||||
const h2 = await store1.put(typeHash, { x: 2 });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const byType = store2.listByType(typeHash);
|
||||
expect(byType).toHaveLength(2);
|
||||
expect(byType).toContain(h1);
|
||||
expect(byType).toContain(h2);
|
||||
});
|
||||
|
||||
test("idempotent put does not duplicate in listByType", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "idempotent-index" });
|
||||
|
||||
const store1 = createFsStore(dir);
|
||||
const hash = await store1.put(typeHash, { n: 7 });
|
||||
await store1.put(typeHash, { n: 7 });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listByType(typeHash)).toEqual([hash]);
|
||||
});
|
||||
|
||||
test("rebuilds _index from .bin files when index is missing", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "migrate" });
|
||||
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1.put(typeHash, { a: 1 });
|
||||
const h2 = await store1.put(typeHash, { a: 2 });
|
||||
|
||||
rmSync(join(dir, "_index"), { recursive: true, force: true });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listByType(typeHash)).toEqual([h1, h2]);
|
||||
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
|
||||
expect(readdirSync(join(dir, "_index"))).toContain(typeHash);
|
||||
});
|
||||
|
||||
test("bootstrap node is listed under its self type after reload", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listByType(hash)).toContain(hash);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// verify on disk-loaded nodes
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -203,7 +287,8 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
|
||||
|
||||
test("verify passes on a disk-loaded bootstrap node", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const hash = await bootstrap(store1);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const node = store2.get(hash) as CasNode;
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { CasNode, Hash, Store } from "@uncaged/json-cas";
|
||||
import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
|
||||
|
||||
import { cborEncode, computeHash, computeSelfHash } from "@uncaged/json-cas";
|
||||
import {
|
||||
BOOTSTRAP_STORE,
|
||||
cborEncode,
|
||||
computeHash,
|
||||
computeSelfHash,
|
||||
} from "@uncaged/json-cas";
|
||||
import { decode } from "cborg";
|
||||
|
||||
const INDEX_DIR = "_index";
|
||||
|
||||
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -31,20 +41,112 @@ function loadDir(dir: string, data: Map<Hash, CasNode>): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function createFsStore(dir: string): Store {
|
||||
function parseIndexFile(content: string): Hash[] {
|
||||
if (content.length === 0) return [];
|
||||
return content.split("\n").filter((line) => line.length > 0) as Hash[];
|
||||
}
|
||||
|
||||
function loadTypeIndex(indexDir: string): Map<Hash, Hash[]> {
|
||||
const typeIndex = new Map<Hash, Hash[]>();
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(indexDir);
|
||||
} catch {
|
||||
return typeIndex;
|
||||
}
|
||||
for (const typeHash of entries) {
|
||||
try {
|
||||
const content = readFileSync(join(indexDir, typeHash), "utf8");
|
||||
typeIndex.set(typeHash as Hash, parseIndexFile(content));
|
||||
} catch {
|
||||
// skip unreadable index files
|
||||
}
|
||||
}
|
||||
return typeIndex;
|
||||
}
|
||||
|
||||
function buildTypeIndexFromNodes(data: Map<Hash, CasNode>): Map<Hash, Hash[]> {
|
||||
const typeIndex = new Map<Hash, Hash[]>();
|
||||
for (const [hash, node] of data) {
|
||||
const list = typeIndex.get(node.type) ?? [];
|
||||
list.push(hash);
|
||||
typeIndex.set(node.type, list);
|
||||
}
|
||||
return typeIndex;
|
||||
}
|
||||
|
||||
function writeTypeIndex(indexDir: string, typeIndex: Map<Hash, Hash[]>): void {
|
||||
mkdirSync(indexDir, { recursive: true });
|
||||
for (const [typeHash, hashes] of typeIndex) {
|
||||
const body = hashes.length > 0 ? `${hashes.join("\n")}\n` : "";
|
||||
writeFileSync(join(indexDir, typeHash), body, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function loadOrMigrateTypeIndex(
|
||||
dir: string,
|
||||
data: Map<Hash, CasNode>,
|
||||
): Map<Hash, Hash[]> {
|
||||
const indexDir = join(dir, INDEX_DIR);
|
||||
if (!existsSync(indexDir)) {
|
||||
const typeIndex = buildTypeIndexFromNodes(data);
|
||||
if (typeIndex.size > 0) {
|
||||
writeTypeIndex(indexDir, typeIndex);
|
||||
}
|
||||
return typeIndex;
|
||||
}
|
||||
return loadTypeIndex(indexDir);
|
||||
}
|
||||
|
||||
function appendToTypeIndex(
|
||||
indexDir: string,
|
||||
typeIndex: Map<Hash, Hash[]>,
|
||||
type: Hash,
|
||||
hash: Hash,
|
||||
): void {
|
||||
mkdirSync(indexDir, { recursive: true });
|
||||
appendFileSync(join(indexDir, type), `${hash}\n`, "utf8");
|
||||
const list = typeIndex.get(type) ?? [];
|
||||
list.push(hash);
|
||||
typeIndex.set(type, list);
|
||||
}
|
||||
|
||||
export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
const data = new Map<Hash, CasNode>();
|
||||
loadDir(dir, data);
|
||||
const indexDir = join(dir, INDEX_DIR);
|
||||
const typeIndex = loadOrMigrateTypeIndex(dir, data);
|
||||
|
||||
return {
|
||||
async put(typeHash: Hash | null, payload: unknown): Promise<Hash> {
|
||||
const hash =
|
||||
typeHash === null
|
||||
? await computeSelfHash(payload)
|
||||
: await computeHash(typeHash, payload);
|
||||
async function putSelfReferencing(payload: unknown): Promise<Hash> {
|
||||
const hash = await computeSelfHash(payload);
|
||||
if (!data.has(hash)) {
|
||||
const node: CasNode = { type: hash, payload, timestamp: Date.now() };
|
||||
data.set(hash, node);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const tmp = join(dir, `${hash}.tmp`);
|
||||
const dest = join(dir, `${hash}.bin`);
|
||||
writeFileSync(
|
||||
tmp,
|
||||
cborEncode({ type: hash, payload, timestamp: node.timestamp }),
|
||||
);
|
||||
renameSync(tmp, dest);
|
||||
|
||||
appendToTypeIndex(indexDir, typeIndex, hash, hash);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
const store: BootstrapCapableStore = {
|
||||
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
|
||||
if (!data.has(hash)) {
|
||||
const type = typeHash === null ? hash : typeHash;
|
||||
const node: CasNode = { type, payload, timestamp: Date.now() };
|
||||
const node: CasNode = {
|
||||
type: typeHash,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
data.set(hash, node);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -52,9 +154,11 @@ export function createFsStore(dir: string): Store {
|
||||
const dest = join(dir, `${hash}.bin`);
|
||||
writeFileSync(
|
||||
tmp,
|
||||
cborEncode({ type, payload, timestamp: node.timestamp }),
|
||||
cborEncode({ type: typeHash, payload, timestamp: node.timestamp }),
|
||||
);
|
||||
renameSync(tmp, dest);
|
||||
|
||||
appendToTypeIndex(indexDir, typeIndex, typeHash, hash);
|
||||
}
|
||||
|
||||
return hash;
|
||||
@@ -68,8 +172,50 @@ export function createFsStore(dir: string): Store {
|
||||
return data.has(hash);
|
||||
},
|
||||
|
||||
list(): Hash[] {
|
||||
return [...data.keys()];
|
||||
listByType(typeHash: Hash): Hash[] {
|
||||
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,
|
||||
};
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"references": [{ "path": "../json-cas" }]
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/json-cas-workflow",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { CasNode } from "@uncaged/json-cas";
|
||||
import {
|
||||
createMemoryStore,
|
||||
getSchema,
|
||||
refs,
|
||||
validate,
|
||||
walk,
|
||||
} from "@uncaged/json-cas";
|
||||
import type { WorkflowSchemaHashes } from "./schemas.js";
|
||||
import { registerWorkflowSchemas } from "./schemas.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: registerWorkflowSchemas() — registers all 11 schemas
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe("registerWorkflowSchemas", () => {
|
||||
test("returns an object with all 11 schema hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const keys: (keyof WorkflowSchemaHashes)[] = [
|
||||
"agent",
|
||||
"roleSchema",
|
||||
"role",
|
||||
"workflow",
|
||||
"threadStart",
|
||||
"threadStep",
|
||||
"threadEnd",
|
||||
"content",
|
||||
"reactSession",
|
||||
"reactTurn",
|
||||
"reactToolCall",
|
||||
];
|
||||
expect(Object.keys(hashes)).toHaveLength(11);
|
||||
for (const key of keys) {
|
||||
expect(hashes[key]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("all hashes are valid 13-char Crockford Base32 strings", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hashes = await registerWorkflowSchemas(store);
|
||||
|
||||
for (const hash of Object.values(hashes)) {
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("all 11 hashes are distinct", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const values = Object.values(hashes);
|
||||
const unique = new Set(values);
|
||||
expect(unique.size).toBe(11);
|
||||
});
|
||||
|
||||
test("is idempotent: repeated calls return the same hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const first = await registerWorkflowSchemas(store);
|
||||
const second = await registerWorkflowSchemas(store);
|
||||
|
||||
for (const key of Object.keys(first) as (keyof WorkflowSchemaHashes)[]) {
|
||||
expect(first[key]).toBe(second[key]);
|
||||
}
|
||||
});
|
||||
|
||||
test("schemas are stored in the store (getSchema returns non-null)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hashes = await registerWorkflowSchemas(store);
|
||||
|
||||
for (const hash of Object.values(hashes)) {
|
||||
expect(getSchema(store, hash)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 2: getSchema() — schema round-trip for each of the 11 types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe("getSchema round-trip", () => {
|
||||
test("agent schema has the expected properties", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { agent } = await registerWorkflowSchemas(store);
|
||||
const schema = getSchema(store, agent);
|
||||
|
||||
expect(schema).not.toBeNull();
|
||||
expect(schema?.type).toBe("object");
|
||||
const props = schema?.properties as Record<string, unknown>;
|
||||
expect(props).toHaveProperty("package");
|
||||
expect(props).toHaveProperty("version");
|
||||
expect(props).toHaveProperty("config");
|
||||
});
|
||||
|
||||
test("role schema references cas_ref for the schema field", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { role } = await registerWorkflowSchemas(store);
|
||||
const schema = getSchema(store, role);
|
||||
|
||||
expect(schema).not.toBeNull();
|
||||
const props = schema?.properties as Record<string, { format?: string }>;
|
||||
expect(props.schema?.format).toBe("cas_ref");
|
||||
});
|
||||
|
||||
test("thread-step schema has six required fields", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadStep } = await registerWorkflowSchemas(store);
|
||||
const schema = getSchema(store, threadStep);
|
||||
|
||||
expect(schema?.required).toHaveLength(6);
|
||||
});
|
||||
|
||||
test("react-turn schema has nested tokens object", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { reactTurn } = await registerWorkflowSchemas(store);
|
||||
const schema = getSchema(store, reactTurn);
|
||||
|
||||
const props = schema?.properties as Record<
|
||||
string,
|
||||
{ type: string; properties?: unknown }
|
||||
>;
|
||||
expect(props.tokens?.type).toBe("object");
|
||||
expect(props.tokens?.properties).toBeDefined();
|
||||
});
|
||||
|
||||
test("workflow schema has roles with additionalProperties cas_ref", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { workflow } = await registerWorkflowSchemas(store);
|
||||
const schema = getSchema(store, workflow);
|
||||
|
||||
const props = schema?.properties as Record<
|
||||
string,
|
||||
{ additionalProperties?: { format?: string } }
|
||||
>;
|
||||
expect(props.roles?.additionalProperties?.format).toBe("cas_ref");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 3: validate() — correct payloads pass for all 11 schema types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe("validate – valid payloads", () => {
|
||||
const HASH = "AAAAAAAAAAAAA";
|
||||
|
||||
test("agent payload is valid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { agent } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(agent, {
|
||||
package: "gpt-4o",
|
||||
version: "2024-11",
|
||||
config: { temperature: 0.7 },
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("role-schema payload is valid (any object)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { roleSchema } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(roleSchema, {
|
||||
type: "object",
|
||||
properties: { answer: { type: "string" } },
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("role payload is valid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { role } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(role, {
|
||||
name: "analyst",
|
||||
description: "Analyses data",
|
||||
systemPrompt: "You are an analyst.",
|
||||
extractPrompt: "Extract the findings.",
|
||||
schema: HASH,
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("workflow payload is valid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { workflow } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(workflow, {
|
||||
name: "research",
|
||||
description: "Research workflow",
|
||||
roles: { analyst: HASH },
|
||||
moderator: [{ from: "analyst", to: "analyst", when: null }],
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("thread-start payload is valid (null parentThread)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadStart } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(threadStart, {
|
||||
workflow: HASH,
|
||||
input: "hello",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: { main: HASH },
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("thread-start payload is valid (non-null parentThread)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadStart } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(threadStart, {
|
||||
workflow: HASH,
|
||||
input: "nested",
|
||||
depth: 1,
|
||||
parentThread: HASH,
|
||||
agents: {},
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("thread-step payload is valid (null previous)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadStep } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(threadStep, {
|
||||
role: "analyst",
|
||||
meta: { attempt: 1 },
|
||||
content: HASH,
|
||||
react: HASH,
|
||||
start: HASH,
|
||||
previous: null,
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("thread-step payload is valid (non-null previous)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadStep } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(threadStep, {
|
||||
role: "analyst",
|
||||
meta: {},
|
||||
content: HASH,
|
||||
react: HASH,
|
||||
start: HASH,
|
||||
previous: HASH,
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("thread-end payload is valid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadEnd } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(threadEnd, {
|
||||
returnCode: 0,
|
||||
summary: "Done",
|
||||
start: HASH,
|
||||
lastStep: HASH,
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("content payload is valid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { content } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(content, { text: "Hello, world!" });
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("react-session payload is valid (empty turns)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { reactSession } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(reactSession, {
|
||||
agent: HASH,
|
||||
role: "analyst",
|
||||
turns: [],
|
||||
totalTokens: 0,
|
||||
durationMs: 42,
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("react-session payload is valid (multiple turns)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { reactSession } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(reactSession, {
|
||||
agent: HASH,
|
||||
role: "analyst",
|
||||
turns: [HASH, HASH],
|
||||
totalTokens: 300,
|
||||
durationMs: 1500,
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("react-turn payload is valid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { reactTurn } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(reactTurn, {
|
||||
input: HASH,
|
||||
output: HASH,
|
||||
toolCalls: [HASH],
|
||||
tokens: { input: 100, output: 50 },
|
||||
latencyMs: 800,
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("react-tool-call payload is valid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { reactToolCall } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(reactToolCall, {
|
||||
name: "search",
|
||||
arguments: HASH,
|
||||
result: HASH,
|
||||
durationMs: 200,
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 4: validate() — invalid payloads fail for representative types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe("validate – invalid payloads", () => {
|
||||
test("agent: missing required field fails", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { agent } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(agent, { package: "gpt-4o", version: "1" });
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("agent: wrong type for config fails", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { agent } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(agent, {
|
||||
package: "gpt-4o",
|
||||
version: "1",
|
||||
config: "not-an-object",
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("role: missing systemPrompt fails", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { role } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(role, {
|
||||
name: "analyst",
|
||||
description: "d",
|
||||
extractPrompt: "e",
|
||||
schema: "AAAAAAAAAAAAA",
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("thread-start: missing depth fails", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadStart } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(threadStart, {
|
||||
workflow: "AAAAAAAAAAAAA",
|
||||
input: "hi",
|
||||
parentThread: null,
|
||||
agents: {},
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("thread-end: returnCode as string fails", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadEnd } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(threadEnd, {
|
||||
returnCode: "ok",
|
||||
summary: "Done",
|
||||
start: "AAAAAAAAAAAAA",
|
||||
lastStep: "AAAAAAAAAAAAA",
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("content: missing text fails", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { content } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(content, {});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("react-turn: tokens.input as string fails", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { reactTurn } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(reactTurn, {
|
||||
input: "AAAAAAAAAAAAA",
|
||||
output: "AAAAAAAAAAAAA",
|
||||
toolCalls: [],
|
||||
tokens: { input: "many", output: 50 },
|
||||
latencyMs: 100,
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("react-tool-call: missing durationMs fails", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { reactToolCall } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(reactToolCall, {
|
||||
name: "tool",
|
||||
arguments: "AAAAAAAAAAAAA",
|
||||
result: "AAAAAAAAAAAAA",
|
||||
});
|
||||
expect(validate(store, store.get(h) as CasNode)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 5: refs() — extracts direct cas_ref fields from node payloads
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe("refs – cas_ref extraction", () => {
|
||||
const HASH_A = "AAAAAAAAAAAAA";
|
||||
const HASH_B = "BBBBBBBBBBBBB";
|
||||
|
||||
test("content node has no cas_ref fields → empty array", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { content } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(content, { text: "hello" });
|
||||
const node = store.get(h) as CasNode;
|
||||
expect(refs(store, node)).toEqual([]);
|
||||
});
|
||||
|
||||
test("role node: refs() returns the schema cas_ref", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { role } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(role, {
|
||||
name: "r",
|
||||
description: "d",
|
||||
systemPrompt: "s",
|
||||
extractPrompt: "e",
|
||||
schema: HASH_A,
|
||||
});
|
||||
const node = store.get(h) as CasNode;
|
||||
expect(refs(store, node)).toContain(HASH_A);
|
||||
});
|
||||
|
||||
test("thread-end: refs() returns start and lastStep", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadEnd } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(threadEnd, {
|
||||
returnCode: 0,
|
||||
summary: "done",
|
||||
start: HASH_A,
|
||||
lastStep: HASH_B,
|
||||
});
|
||||
const node = store.get(h) as CasNode;
|
||||
const result = refs(store, node);
|
||||
expect(result).toContain(HASH_A);
|
||||
expect(result).toContain(HASH_B);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("react-tool-call: refs() returns arguments and result", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { reactToolCall } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(reactToolCall, {
|
||||
name: "search",
|
||||
arguments: HASH_A,
|
||||
result: HASH_B,
|
||||
durationMs: 100,
|
||||
});
|
||||
const node = store.get(h) as CasNode;
|
||||
const result = refs(store, node);
|
||||
expect(result).toContain(HASH_A);
|
||||
expect(result).toContain(HASH_B);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("thread-step: refs() returns content, react, and start (previous null is skipped)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadStep } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(threadStep, {
|
||||
role: "r",
|
||||
meta: {},
|
||||
content: HASH_A,
|
||||
react: HASH_B,
|
||||
start: HASH_A,
|
||||
previous: null,
|
||||
});
|
||||
const node = store.get(h) as CasNode;
|
||||
const result = refs(store, node);
|
||||
expect(result).toContain(HASH_A);
|
||||
expect(result).toContain(HASH_B);
|
||||
});
|
||||
|
||||
test("thread-step: refs() includes previous when non-null", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadStep } = await registerWorkflowSchemas(store);
|
||||
const HASH_C = "CCCCCCCCCCCCC";
|
||||
const h = await store.put(threadStep, {
|
||||
role: "r",
|
||||
meta: {},
|
||||
content: HASH_A,
|
||||
react: HASH_B,
|
||||
start: HASH_A,
|
||||
previous: HASH_C,
|
||||
});
|
||||
const node = store.get(h) as CasNode;
|
||||
const result = refs(store, node);
|
||||
expect(result).toContain(HASH_C);
|
||||
});
|
||||
|
||||
test("react-session: refs() returns the agent cas_ref", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { reactSession } = await registerWorkflowSchemas(store);
|
||||
const h = await store.put(reactSession, {
|
||||
agent: HASH_A,
|
||||
role: "r",
|
||||
turns: [],
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
});
|
||||
const node = store.get(h) as CasNode;
|
||||
expect(refs(store, node)).toContain(HASH_A);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 6: walk() — BFS traversal through linked workflow nodes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe("walk – cross-schema traversal", () => {
|
||||
test("walk visits content node linked from thread-end", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadEnd, content } = await registerWorkflowSchemas(store);
|
||||
|
||||
const contentHash = await store.put(content, { text: "summary text" });
|
||||
const endHash = await store.put(threadEnd, {
|
||||
returnCode: 0,
|
||||
summary: "done",
|
||||
start: contentHash,
|
||||
lastStep: contentHash,
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
walk(store, endHash, (h) => visited.add(h));
|
||||
|
||||
expect(visited.has(endHash)).toBe(true);
|
||||
expect(visited.has(contentHash)).toBe(true);
|
||||
});
|
||||
|
||||
test("walk through role → (schema stored in store)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { role, roleSchema } = await registerWorkflowSchemas(store);
|
||||
|
||||
const schemaDocHash = await store.put(roleSchema, {
|
||||
type: "object",
|
||||
properties: { answer: { type: "string" } },
|
||||
});
|
||||
const roleHash = await store.put(role, {
|
||||
name: "analyst",
|
||||
description: "d",
|
||||
systemPrompt: "s",
|
||||
extractPrompt: "e",
|
||||
schema: schemaDocHash,
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
walk(store, roleHash, (h) => visited.add(h));
|
||||
|
||||
expect(visited.has(roleHash)).toBe(true);
|
||||
expect(visited.has(schemaDocHash)).toBe(true);
|
||||
});
|
||||
|
||||
test("walk handles diamond: two thread-end nodes sharing the same start", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { threadEnd, content } = await registerWorkflowSchemas(store);
|
||||
|
||||
const sharedStart = await store.put(content, { text: "start" });
|
||||
const step1 = await store.put(content, { text: "step1" });
|
||||
const step2 = await store.put(content, { text: "step2" });
|
||||
|
||||
const end1 = await store.put(threadEnd, {
|
||||
returnCode: 0,
|
||||
summary: "path A",
|
||||
start: sharedStart,
|
||||
lastStep: step1,
|
||||
});
|
||||
const end2 = await store.put(threadEnd, {
|
||||
returnCode: 1,
|
||||
summary: "path B",
|
||||
start: sharedStart,
|
||||
lastStep: step2,
|
||||
});
|
||||
|
||||
// Use react-turn as the root linking both ends via input/output
|
||||
const { reactTurn } = await registerWorkflowSchemas(store);
|
||||
const turnHash = await store.put(reactTurn, {
|
||||
input: end1,
|
||||
output: end2,
|
||||
toolCalls: [],
|
||||
tokens: { input: 10, output: 5 },
|
||||
latencyMs: 50,
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
walk(store, turnHash, (h) => visited.add(h));
|
||||
|
||||
expect(visited.has(turnHash)).toBe(true);
|
||||
expect(visited.has(end1)).toBe(true);
|
||||
expect(visited.has(end2)).toBe(true);
|
||||
// sharedStart is reached from both end1 and end2, but visited only once
|
||||
expect(visited.has(sharedStart)).toBe(true);
|
||||
expect(visited.has(step1)).toBe(true);
|
||||
expect(visited.has(step2)).toBe(true);
|
||||
});
|
||||
|
||||
test("walk visits react-tool-call linked from react-turn", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { reactTurn, reactToolCall, content } =
|
||||
await registerWorkflowSchemas(store);
|
||||
|
||||
const argsHash = await store.put(content, { text: '{"q":"test"}' });
|
||||
const resultHash = await store.put(content, { text: '{"r":"ok"}' });
|
||||
const toolCallHash = await store.put(reactToolCall, {
|
||||
name: "search",
|
||||
arguments: argsHash,
|
||||
result: resultHash,
|
||||
durationMs: 120,
|
||||
});
|
||||
|
||||
const inputHash = await store.put(content, { text: "input" });
|
||||
const outputHash = await store.put(content, { text: "output" });
|
||||
const turnHash = await store.put(reactTurn, {
|
||||
input: inputHash,
|
||||
output: outputHash,
|
||||
toolCalls: [],
|
||||
tokens: { input: 80, output: 40 },
|
||||
latencyMs: 600,
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
walk(store, turnHash, (h) => visited.add(h));
|
||||
|
||||
expect(visited.has(turnHash)).toBe(true);
|
||||
expect(visited.has(inputHash)).toBe(true);
|
||||
expect(visited.has(outputHash)).toBe(true);
|
||||
// toolCallHash is not in the turn's cas_ref fields (toolCalls array), only linked manually
|
||||
expect(visited.has(toolCallHash)).toBe(false);
|
||||
|
||||
// walk from toolCallHash to verify it reaches args and result
|
||||
const tcVisited = new Set<string>();
|
||||
walk(store, toolCallHash, (h) => tcVisited.add(h));
|
||||
expect(tcVisited.has(toolCallHash)).toBe(true);
|
||||
expect(tcVisited.has(argsHash)).toBe(true);
|
||||
expect(tcVisited.has(resultHash)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
export {
|
||||
registerWorkflowSchemas,
|
||||
type WorkflowSchemaHashes,
|
||||
} from "./schemas.js";
|
||||
export type {
|
||||
AgentPayload,
|
||||
ContentPayload,
|
||||
ReactSessionPayload,
|
||||
ReactToolCallPayload,
|
||||
ReactTurnPayload,
|
||||
ReactTurnTokens,
|
||||
RolePayload,
|
||||
RoleSchemaPayload,
|
||||
ThreadEndPayload,
|
||||
ThreadStartPayload,
|
||||
ThreadStepPayload,
|
||||
WorkflowPayload,
|
||||
WorkflowTransition,
|
||||
} from "./types.js";
|
||||
@@ -1,236 +0,0 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { type JSONSchema, putSchema } from "@uncaged/json-cas";
|
||||
|
||||
// ── Definition layer ──────────────────────────────────────────────────────────
|
||||
|
||||
const AGENT: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["package", "version", "config"],
|
||||
properties: {
|
||||
package: { type: "string" },
|
||||
version: { type: "string" },
|
||||
config: { type: "object" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** role-schema nodes hold raw JSON Schema documents, so any object is valid. */
|
||||
const ROLE_SCHEMA: JSONSchema = {
|
||||
type: "object",
|
||||
};
|
||||
|
||||
const ROLE: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["name", "description", "systemPrompt", "extractPrompt", "schema"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
systemPrompt: { type: "string" },
|
||||
extractPrompt: { type: "string" },
|
||||
schema: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const WORKFLOW: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["name", "description", "roles", "moderator"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
roles: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
moderator: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["from", "to", "when"],
|
||||
properties: {
|
||||
from: { type: "string" },
|
||||
to: { type: "string" },
|
||||
when: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
// ── Execution layer ───────────────────────────────────────────────────────────
|
||||
|
||||
const THREAD_START: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["workflow", "input", "depth", "parentThread", "agents"],
|
||||
properties: {
|
||||
workflow: { type: "string", format: "cas_ref" },
|
||||
input: { type: "string" },
|
||||
depth: { type: "number" },
|
||||
parentThread: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
agents: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const THREAD_STEP: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["role", "meta", "content", "react", "start", "previous"],
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
meta: { type: "object" },
|
||||
content: { type: "string", format: "cas_ref" },
|
||||
react: { type: "string", format: "cas_ref" },
|
||||
start: { type: "string", format: "cas_ref" },
|
||||
previous: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const THREAD_END: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["returnCode", "summary", "start", "lastStep"],
|
||||
properties: {
|
||||
returnCode: { type: "number" },
|
||||
summary: { type: "string" },
|
||||
start: { type: "string", format: "cas_ref" },
|
||||
lastStep: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const CONTENT: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
// ── React layer ───────────────────────────────────────────────────────────────
|
||||
|
||||
const REACT_SESSION: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["agent", "role", "turns", "totalTokens", "durationMs"],
|
||||
properties: {
|
||||
agent: { type: "string", format: "cas_ref" },
|
||||
role: { type: "string" },
|
||||
turns: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
totalTokens: { type: "number" },
|
||||
durationMs: { type: "number" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const REACT_TURN: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["input", "output", "toolCalls", "tokens", "latencyMs"],
|
||||
properties: {
|
||||
input: { type: "string", format: "cas_ref" },
|
||||
output: { type: "string", format: "cas_ref" },
|
||||
toolCalls: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
tokens: {
|
||||
type: "object",
|
||||
required: ["input", "output"],
|
||||
properties: {
|
||||
input: { type: "number" },
|
||||
output: { type: "number" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
latencyMs: { type: "number" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const REACT_TOOL_CALL: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["name", "arguments", "result", "durationMs"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
arguments: { type: "string", format: "cas_ref" },
|
||||
result: { type: "string", format: "cas_ref" },
|
||||
durationMs: { type: "number" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WorkflowSchemaHashes = {
|
||||
agent: Hash;
|
||||
roleSchema: Hash;
|
||||
role: Hash;
|
||||
workflow: Hash;
|
||||
threadStart: Hash;
|
||||
threadStep: Hash;
|
||||
threadEnd: Hash;
|
||||
content: Hash;
|
||||
reactSession: Hash;
|
||||
reactTurn: Hash;
|
||||
reactToolCall: Hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register all 11 workflow schemas into the given store.
|
||||
* Returns a map from camelCase schema name to its CAS type hash.
|
||||
* Idempotent: safe to call multiple times on the same store.
|
||||
*/
|
||||
export async function registerWorkflowSchemas(
|
||||
store: Store,
|
||||
): Promise<WorkflowSchemaHashes> {
|
||||
const [
|
||||
agent,
|
||||
roleSchema,
|
||||
role,
|
||||
workflow,
|
||||
threadStart,
|
||||
threadStep,
|
||||
threadEnd,
|
||||
content,
|
||||
reactSession,
|
||||
reactTurn,
|
||||
reactToolCall,
|
||||
] = await Promise.all([
|
||||
putSchema(store, AGENT),
|
||||
putSchema(store, ROLE_SCHEMA),
|
||||
putSchema(store, ROLE),
|
||||
putSchema(store, WORKFLOW),
|
||||
putSchema(store, THREAD_START),
|
||||
putSchema(store, THREAD_STEP),
|
||||
putSchema(store, THREAD_END),
|
||||
putSchema(store, CONTENT),
|
||||
putSchema(store, REACT_SESSION),
|
||||
putSchema(store, REACT_TURN),
|
||||
putSchema(store, REACT_TOOL_CALL),
|
||||
]);
|
||||
|
||||
return {
|
||||
agent,
|
||||
roleSchema,
|
||||
role,
|
||||
workflow,
|
||||
threadStart,
|
||||
threadStep,
|
||||
threadEnd,
|
||||
content,
|
||||
reactSession,
|
||||
reactTurn,
|
||||
reactToolCall,
|
||||
};
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { Hash } from "@uncaged/json-cas";
|
||||
|
||||
// ── Definition layer ──────────────────────────────────────────────────────────
|
||||
|
||||
export type AgentPayload = {
|
||||
package: string;
|
||||
version: string;
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** A JSON Schema document stored as-is. */
|
||||
export type RoleSchemaPayload = Record<string, unknown>;
|
||||
|
||||
export type RolePayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
extractPrompt: string;
|
||||
/** cas_ref → role-schema */
|
||||
schema: Hash;
|
||||
};
|
||||
|
||||
export type WorkflowTransition = {
|
||||
from: string;
|
||||
to: string;
|
||||
when: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
/** cas_ref → role */
|
||||
roles: Record<string, Hash>;
|
||||
moderator: WorkflowTransition[];
|
||||
};
|
||||
|
||||
// ── Execution layer ───────────────────────────────────────────────────────────
|
||||
|
||||
export type ThreadStartPayload = {
|
||||
/** cas_ref → workflow */
|
||||
workflow: Hash;
|
||||
input: string;
|
||||
depth: number;
|
||||
/** cas_ref → thread-start | null */
|
||||
parentThread: Hash | null;
|
||||
/** cas_ref → agent */
|
||||
agents: Record<string, Hash>;
|
||||
};
|
||||
|
||||
export type ThreadStepPayload = {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
/** cas_ref → content */
|
||||
content: Hash;
|
||||
/** cas_ref → react-session */
|
||||
react: Hash;
|
||||
/** cas_ref → thread-start */
|
||||
start: Hash;
|
||||
/** cas_ref → thread-step | null */
|
||||
previous: Hash | null;
|
||||
};
|
||||
|
||||
export type ThreadEndPayload = {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
/** cas_ref → thread-start */
|
||||
start: Hash;
|
||||
/** cas_ref → thread-step */
|
||||
lastStep: Hash;
|
||||
};
|
||||
|
||||
export type ContentPayload = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
// ── React layer ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type ReactSessionPayload = {
|
||||
/** cas_ref → agent */
|
||||
agent: Hash;
|
||||
role: string;
|
||||
/** cas_ref → react-turn */
|
||||
turns: Hash[];
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type ReactTurnTokens = {
|
||||
input: number;
|
||||
output: number;
|
||||
};
|
||||
|
||||
export type ReactTurnPayload = {
|
||||
/** cas_ref → content */
|
||||
input: Hash;
|
||||
/** cas_ref → content */
|
||||
output: Hash;
|
||||
/** cas_ref → react-tool-call */
|
||||
toolCalls: Hash[];
|
||||
tokens: ReactTurnTokens;
|
||||
latencyMs: number;
|
||||
};
|
||||
|
||||
export type ReactToolCallPayload = {
|
||||
name: string;
|
||||
/** cas_ref → content (arguments) */
|
||||
arguments: Hash;
|
||||
/** cas_ref → content (result) */
|
||||
result: Hash;
|
||||
durationMs: number;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# @uncaged/json-cas
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feat: add oneOf support to meta-schema validation
|
||||
|
||||
Added `oneOf` to `ALLOWED_SCHEMA_KEYS` and corresponding validation logic
|
||||
in `isValidSchema`. This enables workflow frontmatter schemas that use
|
||||
`oneOf` discriminated unions for multi-exit role definitions.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
|
||||
|
||||
## 0.1.3
|
||||
@@ -0,0 +1,159 @@
|
||||
# @uncaged/json-cas
|
||||
|
||||
Core CAS engine — hashing, schema, store, verify, bootstrap.
|
||||
|
||||
## Overview
|
||||
|
||||
`@uncaged/json-cas` is the foundation of the json-cas monorepo. It defines content-addressed nodes (`CasNode`), the `Store` interface, XXH64-based hashing with deterministic CBOR, JSON Schema registration and validation (including `cas_ref` links between nodes), bootstrap seeding, and integrity verification.
|
||||
|
||||
Other packages build on this layer: `json-cas-fs` provides persistence, and `cli-json-cas` exposes store operations on the command line.
|
||||
|
||||
**Dependencies:** `ajv`, `cborg`, `xxhash-wasm`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @uncaged/json-cas
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
All symbols below are exported from `src/index.ts`.
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
/** 13-character uppercase Crockford Base32 (XXH64) */
|
||||
type Hash = string;
|
||||
|
||||
type CasNode<T = unknown> = {
|
||||
type: Hash;
|
||||
payload: T;
|
||||
timestamp: number; // Unix epoch ms
|
||||
};
|
||||
|
||||
type Store = {
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash>;
|
||||
get(hash: Hash): CasNode | null;
|
||||
has(hash: Hash): boolean;
|
||||
listByType(typeHash: Hash): Hash[];
|
||||
};
|
||||
|
||||
type JSONSchema = Record<string, unknown>;
|
||||
|
||||
type BootstrapCapableStore = Store & {
|
||||
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash>;
|
||||
};
|
||||
```
|
||||
|
||||
### Hashing
|
||||
|
||||
```typescript
|
||||
function computeHash(typeHash: Hash, payload: unknown): Promise<Hash>;
|
||||
function computeSelfHash(payload: unknown): Promise<Hash>;
|
||||
function cborEncode(value: unknown): Uint8Array;
|
||||
```
|
||||
|
||||
`computeHash` — `XXH64(utf8(typeHash) ++ CBOR(payload))` for normal nodes.
|
||||
|
||||
`computeSelfHash` — `XXH64(CBOR(payload))` for bootstrap nodes where `type === hash`.
|
||||
|
||||
### Bootstrap
|
||||
|
||||
```typescript
|
||||
const BOOTSTRAP_STORE: unique symbol;
|
||||
|
||||
async function bootstrap(store: Store): Promise<Hash>;
|
||||
```
|
||||
|
||||
Writes the meta-schema seed node (idempotent). Requires a `BootstrapCapableStore` (e.g. from `createMemoryStore()`).
|
||||
|
||||
### Schema
|
||||
|
||||
```typescript
|
||||
class SchemaValidationError extends Error;
|
||||
|
||||
async function putSchema(store: Store, jsonSchema: JSONSchema): Promise<Hash>;
|
||||
function getSchema(store: Store, typeHash: Hash): JSONSchema | null;
|
||||
function validate(store: Store, node: CasNode): boolean;
|
||||
function refs(store: Store, node: CasNode): Hash[];
|
||||
function walk(
|
||||
store: Store,
|
||||
rootHash: Hash,
|
||||
visitor: (hash: Hash, node: CasNode) => void,
|
||||
): void;
|
||||
```
|
||||
|
||||
- `putSchema` — stores a schema typed by the meta-schema; returned hash is the `typeHash` for conforming payloads.
|
||||
- `refs` — collects all `format: "cas_ref"` values in the payload per schema shape.
|
||||
- `walk` — BFS from `rootHash`, following `cas_ref` edges; cycles are visited once.
|
||||
|
||||
### Store
|
||||
|
||||
```typescript
|
||||
function createMemoryStore(): BootstrapCapableStore;
|
||||
```
|
||||
|
||||
In-memory `Store` with type indexing, suitable for tests and ephemeral use.
|
||||
|
||||
### Verify
|
||||
|
||||
```typescript
|
||||
async function verify(hash: Hash, node: CasNode): Promise<boolean>;
|
||||
```
|
||||
|
||||
Recomputes hash from `node` and compares to `hash` (self-referencing vs normal rules).
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
bootstrap,
|
||||
createMemoryStore,
|
||||
putSchema,
|
||||
refs,
|
||||
validate,
|
||||
walk,
|
||||
} from "@uncaged/json-cas";
|
||||
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
|
||||
const personType = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
friend: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const aliceHash = await store.put(personType, { name: "Alice" });
|
||||
const bobHash = await store.put(personType, {
|
||||
name: "Bob",
|
||||
friend: aliceHash,
|
||||
});
|
||||
|
||||
const bob = store.get(bobHash)!;
|
||||
console.log(validate(store, bob)); // true
|
||||
console.log(refs(store, bob)); // [aliceHash]
|
||||
walk(store, bobHash, (h) => console.log(h)); // bobHash, aliceHash
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `types.ts` | `Hash`, `CasNode`, `Store` |
|
||||
| `hash.ts` | `computeHash`, `computeSelfHash` |
|
||||
| `cbor.ts` | Deterministic CBOR encoding |
|
||||
| `bootstrap-capable.ts` | `BOOTSTRAP_STORE` symbol and capability check |
|
||||
| `bootstrap.ts` | Meta-schema seed and `bootstrap()` |
|
||||
| `store.ts` | `createMemoryStore()` |
|
||||
| `mem-store.ts` | Alternate in-memory store (tests only; not exported) |
|
||||
| `schema.ts` | Schema put/get/validate, `refs`, `walk` |
|
||||
| `verify.ts` | Node integrity verification |
|
||||
| `index.ts` | Public exports |
|
||||
|
||||
Tests live in `src/*.test.ts` and `tests/`.
|
||||
@@ -1,13 +1,22 @@
|
||||
{
|
||||
"name": "@uncaged/json-cas",
|
||||
"version": "0.1.1",
|
||||
"version": "0.5.3",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Hash, Store } from "./types.js";
|
||||
|
||||
/** @internal Store implementations attach this for bootstrap() only. */
|
||||
export const BOOTSTRAP_STORE = Symbol.for("@uncaged/json-cas/bootstrap-store");
|
||||
|
||||
export type BootstrapCapableStore = Store & {
|
||||
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash>;
|
||||
};
|
||||
|
||||
export function isBootstrapCapableStore(
|
||||
store: Store,
|
||||
): store is BootstrapCapableStore {
|
||||
return (
|
||||
typeof (store as BootstrapCapableStore)[BOOTSTRAP_STORE] === "function"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { getSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Built-in Schema Registration Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bootstrap - Built-in Schemas", () => {
|
||||
test("should return map of built-in schema aliases to hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
// Should return object with 6 aliases
|
||||
expect(builtinSchemas).toHaveProperty("@schema");
|
||||
expect(builtinSchemas).toHaveProperty("@string");
|
||||
expect(builtinSchemas).toHaveProperty("@number");
|
||||
expect(builtinSchemas).toHaveProperty("@object");
|
||||
expect(builtinSchemas).toHaveProperty("@array");
|
||||
expect(builtinSchemas).toHaveProperty("@bool");
|
||||
|
||||
// All values should be valid hashes
|
||||
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("should register @schema as meta-schema alias", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) throw new Error("@schema not found");
|
||||
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
expect(metaSchema).not.toBeNull();
|
||||
expect(metaSchema?.type).toBe("object");
|
||||
expect(metaSchema?.description).toBe("json-cas JSON Schema meta-schema");
|
||||
});
|
||||
|
||||
test("should register @string schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const stringHash = builtinSchemas["@string"];
|
||||
if (!stringHash) throw new Error("@string not found");
|
||||
|
||||
const stringSchema = getSchema(store, stringHash);
|
||||
expect(stringSchema).toEqual({ type: "string" });
|
||||
});
|
||||
|
||||
test("should register @number schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const numberHash = builtinSchemas["@number"];
|
||||
if (!numberHash) throw new Error("@number not found");
|
||||
|
||||
const numberSchema = getSchema(store, numberHash);
|
||||
expect(numberSchema).toEqual({ type: "number" });
|
||||
});
|
||||
|
||||
test("should register @object schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const objectHash = builtinSchemas["@object"];
|
||||
if (!objectHash) throw new Error("@object not found");
|
||||
|
||||
const objectSchema = getSchema(store, objectHash);
|
||||
expect(objectSchema).toEqual({ type: "object" });
|
||||
});
|
||||
|
||||
test("should register @array schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const arrayHash = builtinSchemas["@array"];
|
||||
if (!arrayHash) throw new Error("@array not found");
|
||||
|
||||
const arraySchema = getSchema(store, arrayHash);
|
||||
expect(arraySchema).toEqual({ type: "array" });
|
||||
});
|
||||
|
||||
test("should register @bool schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const boolHash = builtinSchemas["@bool"];
|
||||
if (!boolHash) throw new Error("@bool not found");
|
||||
|
||||
const boolSchema = getSchema(store, boolHash);
|
||||
expect(boolSchema).toEqual({ type: "boolean" });
|
||||
});
|
||||
|
||||
test("should return same hashes on repeated bootstrap calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const first = await bootstrap(store);
|
||||
const second = await bootstrap(store);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
|
||||
// Verify each alias points to same hash
|
||||
expect(first["@string"]).toBe(second["@string"]);
|
||||
expect(first["@number"]).toBe(second["@number"]);
|
||||
expect(first["@object"]).toBe(second["@object"]);
|
||||
expect(first["@array"]).toBe(second["@array"]);
|
||||
expect(first["@bool"]).toBe(second["@bool"]);
|
||||
expect(first["@schema"]).toBe(second["@schema"]);
|
||||
});
|
||||
|
||||
test("all built-in schemas should be typed by meta-schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) throw new Error("@schema not found");
|
||||
|
||||
for (const [alias, hash] of Object.entries(builtinSchemas)) {
|
||||
if (alias === "@schema") continue; // meta-schema is self-typed
|
||||
|
||||
const node = store.get(hash);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.type).toBe(metaHash);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,95 @@
|
||||
import {
|
||||
BOOTSTRAP_STORE,
|
||||
isBootstrapCapableStore,
|
||||
} from "./bootstrap-capable.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
|
||||
const JSON_SCHEMA_TYPES = [
|
||||
"string",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The meta-schema seed payload: describes the structure of every CAS node.
|
||||
* This is the root type from which all other type nodes derive.
|
||||
* Self-describing JSON Schema meta-schema for the supported schema subset.
|
||||
* Stored as the bootstrap node's payload; its hash equals the node's type field.
|
||||
*/
|
||||
const BOOTSTRAP_PAYLOAD = {
|
||||
description: "json-cas meta-schema seed",
|
||||
hashAlgorithm: "xxh64",
|
||||
hashEncoding: "crockford-base32-13",
|
||||
nodeSchema: {
|
||||
payload: "any",
|
||||
timestamp: "number",
|
||||
type: "Hash",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
description: "json-cas JSON Schema meta-schema",
|
||||
properties: {
|
||||
type: {
|
||||
anyOf: [
|
||||
{ type: "string", enum: [...JSON_SCHEMA_TYPES] },
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", enum: [...JSON_SCHEMA_TYPES] },
|
||||
},
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "object", additionalProperties: false },
|
||||
},
|
||||
required: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
additionalProperties: {
|
||||
anyOf: [
|
||||
{ type: "boolean" },
|
||||
{ type: "object", additionalProperties: false },
|
||||
],
|
||||
},
|
||||
anyOf: {
|
||||
type: "array",
|
||||
items: { type: "object", additionalProperties: false },
|
||||
},
|
||||
oneOf: {
|
||||
type: "array",
|
||||
items: { type: "object", additionalProperties: false },
|
||||
},
|
||||
items: { type: "object", additionalProperties: false },
|
||||
format: { type: "string" },
|
||||
title: { type: "string" },
|
||||
enum: { type: "array" },
|
||||
const: {},
|
||||
description: { type: "string" },
|
||||
},
|
||||
payloadEncoding: "cbor-rfc8949-deterministic",
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Write the meta-schema seed node into the store.
|
||||
* The returned hash equals the node's own type field (self-referencing).
|
||||
* Idempotent: calling bootstrap multiple times returns the same hash.
|
||||
* Write the meta-schema seed node into the store and register built-in schemas.
|
||||
* The returned object contains aliases for the meta-schema and 5 primitive schemas.
|
||||
* Idempotent: calling bootstrap multiple times returns the same hashes.
|
||||
*/
|
||||
export async function bootstrap(store: Store): Promise<Hash> {
|
||||
return store.put(null, BOOTSTRAP_PAYLOAD);
|
||||
export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
|
||||
if (!isBootstrapCapableStore(store)) {
|
||||
throw new Error("Store does not support bootstrap");
|
||||
}
|
||||
|
||||
// 1. Bootstrap the meta-schema (self-referential)
|
||||
const metaHash = await store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
||||
|
||||
// 2. Register built-in primitive schemas directly (without putSchema to avoid recursion)
|
||||
const stringHash = await store.put(metaHash, { type: "string" });
|
||||
const numberHash = await store.put(metaHash, { type: "number" });
|
||||
const objectHash = await store.put(metaHash, { type: "object" });
|
||||
const arrayHash = await store.put(metaHash, { type: "array" });
|
||||
const boolHash = await store.put(metaHash, { type: "boolean" });
|
||||
|
||||
// 3. Return map of aliases to hashes
|
||||
return {
|
||||
"@schema": metaHash,
|
||||
"@string": stringHash,
|
||||
"@number": numberHash,
|
||||
"@object": objectHash,
|
||||
"@array": arrayHash,
|
||||
"@bool": boolHash,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { bootstrap } from "./bootstrap.js";
|
||||
import { cborEncode } from "./cbor.js";
|
||||
import { computeHash, computeSelfHash } from "./hash.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { CasNode } from "./types.js";
|
||||
import type { CasNode, Store } from "./types.js";
|
||||
import { verify } from "./verify.js";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -97,7 +97,18 @@ describe("createMemoryStore – put and get", () => {
|
||||
const h1 = await store.put(typeHash, { n: 42 });
|
||||
const h2 = await store.put(typeHash, { n: 42 });
|
||||
expect(h1).toBe(h2);
|
||||
expect(store.list()).toHaveLength(1);
|
||||
expect(store.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("put does not create self-referencing nodes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const payload = { name: "type-descriptor" };
|
||||
const typeHash = await computeSelfHash(payload);
|
||||
const hash = await store.put(typeHash, payload);
|
||||
|
||||
const node = store.get(hash);
|
||||
expect(node?.type).toBe(typeHash);
|
||||
expect(node?.type).not.toBe(hash);
|
||||
});
|
||||
|
||||
test("timestamp is preserved on second put (idempotency)", async () => {
|
||||
@@ -116,9 +127,9 @@ describe("createMemoryStore – put and get", () => {
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Step 4: store.has() and store.list()
|
||||
// Step 4: store.has()
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createMemoryStore – has and list", () => {
|
||||
describe("createMemoryStore – has", () => {
|
||||
test("has returns false before put, true after", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
@@ -129,7 +140,7 @@ describe("createMemoryStore – has and list", () => {
|
||||
expect(store.has(hash)).toBe(true);
|
||||
});
|
||||
|
||||
test("list returns all stored hashes", async () => {
|
||||
test("listByType returns all stored hashes for a type", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
|
||||
@@ -137,16 +148,66 @@ describe("createMemoryStore – has and list", () => {
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
const h3 = await store.put(typeHash, { a: 3 });
|
||||
|
||||
const all = store.list();
|
||||
const all = store.listByType(typeHash);
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all).toContain(h1);
|
||||
expect(all).toContain(h2);
|
||||
expect(all).toContain(h3);
|
||||
});
|
||||
|
||||
test("list returns empty array on fresh store", () => {
|
||||
test("listByType returns empty array on fresh store", () => {
|
||||
const store = createMemoryStore();
|
||||
expect(store.list()).toEqual([]);
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Step 4b: store.listByType()
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createMemoryStore – listByType", () => {
|
||||
test("returns empty array for unknown type", () => {
|
||||
const store = createMemoryStore();
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns all hashes for the given type", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
const otherType = await computeSelfHash({ name: "other" });
|
||||
|
||||
const h1 = await store.put(typeHash, { a: 1 });
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
await store.put(otherType, { b: 1 });
|
||||
|
||||
const byType = store.listByType(typeHash);
|
||||
expect(byType).toHaveLength(2);
|
||||
expect(byType).toContain(h1);
|
||||
expect(byType).toContain(h2);
|
||||
});
|
||||
|
||||
test("idempotent put does not duplicate in listByType", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
|
||||
const h1 = await store.put(typeHash, { n: 1 });
|
||||
await store.put(typeHash, { n: 1 });
|
||||
|
||||
expect(store.listByType(typeHash)).toEqual([h1]);
|
||||
});
|
||||
|
||||
test("bootstrap node is listed under its self type", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const hash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
// All built-in schemas should be typed by the meta-schema
|
||||
const allTypedByMeta = store.listByType(hash);
|
||||
expect(allTypedByMeta).toContain(hash); // meta-schema itself
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@string"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@number"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@object"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@array"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@bool"] ?? "");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,44 +252,71 @@ describe("verify", () => {
|
||||
// Step 6: bootstrap()
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("bootstrap", () => {
|
||||
test("returns a valid 13-char hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await bootstrap(store);
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
test("throws when store lacks internal bootstrap path", async () => {
|
||||
const store: Store = {
|
||||
put: async () => "0000000000000",
|
||||
get: () => null,
|
||||
has: () => false,
|
||||
listByType: () => [],
|
||||
};
|
||||
await expect(bootstrap(store)).rejects.toThrow(
|
||||
"Store does not support bootstrap",
|
||||
);
|
||||
});
|
||||
|
||||
test("node is stored and retrievable", async () => {
|
||||
test("returns a map with 6 built-in schema aliases", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
expect(store.has(hash)).toBe(true);
|
||||
const node = store.get(hash);
|
||||
expect(builtinSchemas).toHaveProperty("@schema");
|
||||
expect(builtinSchemas).toHaveProperty("@string");
|
||||
expect(builtinSchemas).toHaveProperty("@number");
|
||||
expect(builtinSchemas).toHaveProperty("@object");
|
||||
expect(builtinSchemas).toHaveProperty("@array");
|
||||
expect(builtinSchemas).toHaveProperty("@bool");
|
||||
|
||||
// All values should be valid hashes
|
||||
for (const hash of Object.values(builtinSchemas)) {
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("meta-schema node is stored and retrievable", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
expect(store.has(metaHash)).toBe(true);
|
||||
const node = store.get(metaHash);
|
||||
expect(node).not.toBeNull();
|
||||
});
|
||||
|
||||
test("node is self-referencing: type === hash", async () => {
|
||||
test("meta-schema node is self-referencing: type === hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await bootstrap(store);
|
||||
const node = store.get(hash) as CasNode;
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const node = store.get(metaHash) as CasNode;
|
||||
|
||||
expect(node.type).toBe(hash);
|
||||
expect(node.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
test("bootstrap node passes verify()", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await bootstrap(store);
|
||||
const node = store.get(hash) as CasNode;
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const node = store.get(metaHash) as CasNode;
|
||||
|
||||
expect(await verify(hash, node)).toBe(true);
|
||||
expect(await verify(metaHash, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("bootstrap is idempotent: same hash on repeated calls", async () => {
|
||||
test("bootstrap is idempotent: same hashes on repeated calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const h1 = await bootstrap(store);
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toBe(h2);
|
||||
expect(store.list()).toHaveLength(1);
|
||||
expect(h1).toEqual(h2);
|
||||
// All 6 built-in schemas should be typed by the meta-schema
|
||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
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, putSchema, refs, validate, walk } from "./schema.js";
|
||||
export {
|
||||
getSchema,
|
||||
putSchema,
|
||||
refs,
|
||||
SchemaValidationError,
|
||||
validate,
|
||||
walk,
|
||||
} from "./schema.js";
|
||||
export { createMemoryStore } from "./store.js";
|
||||
export type { CasNode, Hash, Store } from "./types.js";
|
||||
export type { Variable } from "./variable.js";
|
||||
export {
|
||||
CasNodeNotFoundError,
|
||||
createVariableStore,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
VariableStore,
|
||||
} from "./variable-store.js";
|
||||
export { verify } from "./verify.js";
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { CasNode, Hash } from "./types.js";
|
||||
|
||||
/** In-memory store wrapper used by schema validation tests. */
|
||||
export class MemStore implements BootstrapCapableStore {
|
||||
readonly #inner: BootstrapCapableStore;
|
||||
|
||||
constructor() {
|
||||
this.#inner = createMemoryStore();
|
||||
}
|
||||
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
return this.#inner.put(typeHash, payload);
|
||||
}
|
||||
|
||||
get(hash: Hash): CasNode | null {
|
||||
return this.#inner.get(hash);
|
||||
}
|
||||
|
||||
has(hash: Hash): boolean {
|
||||
return this.#inner.has(hash);
|
||||
}
|
||||
|
||||
listByType(typeHash: Hash): Hash[] {
|
||||
return this.#inner.listByType(typeHash);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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("🌍");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -29,7 +29,8 @@ describe("putSchema", () => {
|
||||
|
||||
test("schema node type equals the meta-schema hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const node = store.get(schemaHash) as CasNode;
|
||||
|
||||
@@ -355,7 +356,8 @@ describe("walk", () => {
|
||||
describe("bootstrap meta-schema self-reference", () => {
|
||||
test("metaNode.type === metaHash (self-referencing)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaNode = store.get(metaHash) as CasNode;
|
||||
|
||||
expect(metaNode.type).toBe(metaHash);
|
||||
@@ -363,7 +365,8 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("schema nodes have type === metaHash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const schemaNode = store.get(schemaHash) as CasNode;
|
||||
|
||||
@@ -372,7 +375,8 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("data nodes have type === schemaHash (not metaHash)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { val: { type: "number" } },
|
||||
@@ -386,7 +390,8 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("bootstrap is idempotent across putSchema calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
await putSchema(store, { type: "string" });
|
||||
await putSchema(store, { type: "number" });
|
||||
|
||||
@@ -4,7 +4,10 @@ import * as AjvModule from "ajv";
|
||||
// but tsc with verbatimModuleSyntax sees the namespace wrapper.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: CJS interop
|
||||
const Ajv = ((AjvModule as any).default ?? AjvModule) as {
|
||||
new (): { addFormat(name: string, re: RegExp): void; validate(schema: unknown, data: unknown): boolean };
|
||||
new (): {
|
||||
addFormat(name: string, re: RegExp): void;
|
||||
validate(schema: unknown, data: unknown): boolean;
|
||||
};
|
||||
};
|
||||
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
@@ -12,9 +15,125 @@ import type { CasNode, Hash, Store } from "./types.js";
|
||||
|
||||
export type JSONSchema = Record<string, unknown>;
|
||||
|
||||
export class SchemaValidationError extends Error {
|
||||
override readonly name = "SchemaValidationError";
|
||||
}
|
||||
|
||||
const ajv = new Ajv();
|
||||
ajv.addFormat("cas_ref", /^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
|
||||
const ALLOWED_SCHEMA_KEYS = new Set([
|
||||
"type",
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"items",
|
||||
"format",
|
||||
"title",
|
||||
"enum",
|
||||
"const",
|
||||
"description",
|
||||
]);
|
||||
|
||||
const JSON_SCHEMA_TYPES = new Set([
|
||||
"string",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null",
|
||||
]);
|
||||
|
||||
function isValidTypeValue(type: unknown): boolean {
|
||||
if (typeof type === "string") {
|
||||
return JSON_SCHEMA_TYPES.has(type);
|
||||
}
|
||||
if (Array.isArray(type)) {
|
||||
if (type.length === 0) return false;
|
||||
return type.every(
|
||||
(entry) => typeof entry === "string" && JSON_SCHEMA_TYPES.has(entry),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isValidSchema(value: unknown): boolean {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const schema = value as JSONSchema;
|
||||
for (const key of Object.keys(schema)) {
|
||||
if (!ALLOWED_SCHEMA_KEYS.has(key)) return false;
|
||||
}
|
||||
|
||||
if ("type" in schema && !isValidTypeValue(schema.type)) return false;
|
||||
|
||||
if ("properties" in schema) {
|
||||
const properties = schema.properties;
|
||||
if (
|
||||
properties === null ||
|
||||
typeof properties !== "object" ||
|
||||
Array.isArray(properties)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
for (const nested of Object.values(properties as Record<string, unknown>)) {
|
||||
if (!isValidSchema(nested)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("required" in schema) {
|
||||
if (!Array.isArray(schema.required)) return false;
|
||||
for (const entry of schema.required) {
|
||||
if (typeof entry !== "string") return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("additionalProperties" in schema) {
|
||||
const additionalProperties = schema.additionalProperties;
|
||||
if (typeof additionalProperties === "boolean") {
|
||||
// allowed
|
||||
} else if (!isValidSchema(additionalProperties)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("anyOf" in schema) {
|
||||
if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) return false;
|
||||
for (const entry of schema.anyOf) {
|
||||
if (!isValidSchema(entry)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("oneOf" in schema) {
|
||||
if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) return false;
|
||||
for (const entry of schema.oneOf) {
|
||||
if (!isValidSchema(entry)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("items" in schema && !isValidSchema(schema.items)) return false;
|
||||
if ("format" in schema && typeof schema.format !== "string") return false;
|
||||
if ("title" in schema && typeof schema.title !== "string") return false;
|
||||
if ("description" in schema && typeof schema.description !== "string") {
|
||||
return false;
|
||||
}
|
||||
if ("enum" in schema) {
|
||||
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMetaSchemaNode(store: Store, node: CasNode): boolean {
|
||||
const schema = getSchema(store, node.type);
|
||||
return schema !== null && schema === node.payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a JSON Schema as a CAS node typed by the meta-schema hash.
|
||||
* The returned hash becomes the typeHash for nodes that conform to this schema.
|
||||
@@ -23,7 +142,16 @@ export async function putSchema(
|
||||
store: Store,
|
||||
jsonSchema: JSONSchema,
|
||||
): Promise<Hash> {
|
||||
const metaHash = await bootstrap(store);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"];
|
||||
if (!metaHash) {
|
||||
throw new Error("Meta-schema not found in bootstrap result");
|
||||
}
|
||||
if (!isValidSchema(jsonSchema)) {
|
||||
throw new SchemaValidationError(
|
||||
"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema",
|
||||
);
|
||||
}
|
||||
return store.put(metaHash, jsonSchema);
|
||||
}
|
||||
|
||||
@@ -44,6 +172,9 @@ export function getSchema(store: Store, typeHash: Hash): JSONSchema | null {
|
||||
export function validate(store: Store, node: CasNode): boolean {
|
||||
const schema = getSchema(store, node.type);
|
||||
if (schema === null) return false;
|
||||
if (isMetaSchemaNode(store, node)) {
|
||||
return isValidSchema(node.payload);
|
||||
}
|
||||
return ajv.validate(
|
||||
schema as Parameters<typeof ajv.validate>[0],
|
||||
node.payload,
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
import {
|
||||
BOOTSTRAP_STORE,
|
||||
type BootstrapCapableStore,
|
||||
} from "./bootstrap-capable.js";
|
||||
import { computeHash, computeSelfHash } from "./hash.js";
|
||||
import type { CasNode, Hash, Store } from "./types.js";
|
||||
import type { CasNode, Hash } from "./types.js";
|
||||
|
||||
export function createMemoryStore(): Store {
|
||||
export function createMemoryStore(): BootstrapCapableStore {
|
||||
const data = new Map<Hash, CasNode>();
|
||||
const byType = new Map<Hash, Set<Hash>>();
|
||||
|
||||
return {
|
||||
async put(typeHash: Hash | null, payload: unknown): Promise<Hash> {
|
||||
const hash =
|
||||
typeHash === null
|
||||
? await computeSelfHash(payload)
|
||||
: await computeHash(typeHash, payload);
|
||||
function indexHash(type: Hash, hash: Hash): void {
|
||||
let set = byType.get(type);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
byType.set(type, set);
|
||||
}
|
||||
set.add(hash);
|
||||
}
|
||||
|
||||
async function putSelfReferencing(payload: unknown): Promise<Hash> {
|
||||
const hash = await computeSelfHash(payload);
|
||||
if (!data.has(hash)) {
|
||||
data.set(hash, { type: hash, payload, timestamp: Date.now() });
|
||||
indexHash(hash, hash);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
const store: BootstrapCapableStore = {
|
||||
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
|
||||
if (!data.has(hash)) {
|
||||
const type = typeHash === null ? hash : typeHash;
|
||||
data.set(hash, { type, payload, timestamp: Date.now() });
|
||||
data.set(hash, { type: typeHash, payload, timestamp: Date.now() });
|
||||
indexHash(typeHash, hash);
|
||||
}
|
||||
|
||||
return hash;
|
||||
@@ -27,8 +47,32 @@ export function createMemoryStore(): Store {
|
||||
return data.has(hash);
|
||||
},
|
||||
|
||||
list(): Hash[] {
|
||||
return [...data.keys()];
|
||||
listByType(typeHash: Hash): Hash[] {
|
||||
const set = byType.get(typeHash);
|
||||
return set ? [...set] : [];
|
||||
},
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ export type CasNode<T = unknown> = {
|
||||
|
||||
/**
|
||||
* Content-addressable store interface.
|
||||
* put(null, payload) creates a self-referencing (bootstrap) node.
|
||||
* Self-referencing nodes are created only via bootstrap().
|
||||
*/
|
||||
export type Store = {
|
||||
put(typeHash: Hash | null, payload: unknown): Promise<Hash>;
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash>;
|
||||
get(hash: Hash): CasNode | null;
|
||||
has(hash: Hash): boolean;
|
||||
list(): Hash[];
|
||||
listByType(typeHash: Hash): Hash[];
|
||||
listAll(): Hash[];
|
||||
delete(hash: Hash): void;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,723 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
/**
|
||||
* Custom error types for variable operations
|
||||
*/
|
||||
export class VariableNotFoundError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public variableSchema: Hash,
|
||||
) {
|
||||
super(`Variable not found: name=${variableName}, schema=${variableSchema}`);
|
||||
this.name = "VariableNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidVariableNameError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public reason: string,
|
||||
) {
|
||||
super(`Invalid variable name "${variableName}": ${reason}`);
|
||||
this.name = "InvalidVariableNameError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SchemaMismatchError extends Error {
|
||||
constructor(
|
||||
public expected: string,
|
||||
public actual: string,
|
||||
) {
|
||||
super(`Schema mismatch: expected ${expected}, got ${actual}`);
|
||||
this.name = "SchemaMismatchError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CasNodeNotFoundError extends Error {
|
||||
constructor(hash: string) {
|
||||
super(`CAS node not found: ${hash}`);
|
||||
this.name = "CasNodeNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TagLabelConflictError extends Error {
|
||||
constructor(
|
||||
public conflictName: string,
|
||||
public existingType: "tag" | "label",
|
||||
public attemptedType: "tag" | "label",
|
||||
) {
|
||||
super(`Conflict: '${conflictName}' already exists as a ${existingType}`);
|
||||
this.name = "TagLabelConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTagFormatError extends Error {
|
||||
constructor(tag: string) {
|
||||
super(`Invalid tag format: ${tag}`);
|
||||
this.name = "InvalidTagFormatError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable store with SQLite backend
|
||||
*/
|
||||
export class VariableStore {
|
||||
private db: Database;
|
||||
|
||||
constructor(
|
||||
dbPath: string,
|
||||
private casStore: Store,
|
||||
) {
|
||||
this.db = new Database(dbPath, { create: true });
|
||||
// Enable foreign keys
|
||||
this.db.exec("PRAGMA foreign_keys = ON");
|
||||
this.initDb();
|
||||
}
|
||||
|
||||
private initDb(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS variables (
|
||||
name TEXT NOT NULL,
|
||||
schema TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created INTEGER NOT NULL,
|
||||
updated INTEGER NOT NULL,
|
||||
PRIMARY KEY (name, schema)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_var_name ON variables(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variable_tags (
|
||||
variable_name TEXT NOT NULL,
|
||||
variable_schema TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (variable_name, variable_schema, key),
|
||||
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variable_labels (
|
||||
variable_name TEXT NOT NULL,
|
||||
variable_schema TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (variable_name, variable_schema, name),
|
||||
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_tag_key_value ON variable_tags(key, value);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_label_name ON variable_labels(name);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate variable name format
|
||||
* @ is allowed at the start of the first segment (system-reserved)
|
||||
*/
|
||||
private validateName(name: string): void {
|
||||
// Rule 1: Cannot be empty
|
||||
if (name === "") {
|
||||
throw new InvalidVariableNameError(name, "Name cannot be empty");
|
||||
}
|
||||
|
||||
// Rule 2: No leading slash
|
||||
if (name.startsWith("/")) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot start with leading slash",
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 3: No trailing slash
|
||||
if (name.endsWith("/")) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot end with trailing slash",
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment)
|
||||
const segments = name.split("/");
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i] as string;
|
||||
if (segment === "") {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name contains empty segment (consecutive slashes //)",
|
||||
);
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
// First segment can start with @, all segments can contain [a-zA-Z0-9._-]
|
||||
const regex = i === 0 ? /^@?[a-zA-Z0-9._-]+$/ : /^[a-zA-Z0-9._-]+$/;
|
||||
if (!regex.test(segment)) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
`Segment "${segment}" contains invalid characters (only ${i === 0 ? "@, " : ""}a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract schema hash from CAS node
|
||||
*/
|
||||
private extractSchema(hash: string): string {
|
||||
const node = this.casStore.get(hash);
|
||||
if (node === null) {
|
||||
throw new CasNodeNotFoundError(hash);
|
||||
}
|
||||
return node.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tags for a variable
|
||||
*/
|
||||
private loadTags(name: string, schema: Hash): Record<string, string> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT key, value
|
||||
FROM variable_tags
|
||||
WHERE variable_name = ? AND variable_schema = ?
|
||||
`);
|
||||
|
||||
const rows = stmt.all(name, schema) as Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
}>;
|
||||
const tags: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
tags[row.key] = row.value;
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load labels for a variable
|
||||
*/
|
||||
private loadLabels(name: string, schema: Hash): string[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT name
|
||||
FROM variable_labels
|
||||
WHERE variable_name = ? AND variable_schema = ?
|
||||
ORDER BY name ASC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(name, schema) as Array<{ name: string }>;
|
||||
return rows.map((row) => row.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a variable (upsert: create or update)
|
||||
*/
|
||||
set(
|
||||
name: string,
|
||||
value: string,
|
||||
options?: {
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
},
|
||||
): Variable {
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const schema = this.extractSchema(value);
|
||||
|
||||
// Check if variable exists
|
||||
const existing = this.get(name, schema);
|
||||
|
||||
if (existing !== null) {
|
||||
// Update existing variable
|
||||
const now = Date.now();
|
||||
|
||||
// If options provided, use them; otherwise preserve existing
|
||||
const tags = options?.tags ?? existing.tags;
|
||||
const labels = options?.labels ?? existing.labels;
|
||||
|
||||
// Check for tag/label conflicts when updating with new options
|
||||
if (options !== undefined) {
|
||||
const tagKeys = Object.keys(tags);
|
||||
for (const key of tagKeys) {
|
||||
if (labels.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
try {
|
||||
// Update value and timestamp
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE variables
|
||||
SET value = ?, updated = ?
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
updateStmt.run(value, now, name, schema);
|
||||
|
||||
// If options provided, update tags/labels
|
||||
if (options !== undefined) {
|
||||
// Delete existing tags and labels
|
||||
this.db
|
||||
.prepare(`
|
||||
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ?
|
||||
`)
|
||||
.run(name, schema);
|
||||
|
||||
this.db
|
||||
.prepare(`
|
||||
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ?
|
||||
`)
|
||||
.run(name, schema);
|
||||
|
||||
// Insert new tags
|
||||
const tagKeys = Object.keys(tags);
|
||||
if (tagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, val] of Object.entries(tags)) {
|
||||
tagStmt.run(name, schema, key, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new labels
|
||||
if (labels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const labelName of labels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
created: existing.created,
|
||||
updated: now,
|
||||
tags,
|
||||
labels: [...labels],
|
||||
};
|
||||
}
|
||||
|
||||
// Create new variable
|
||||
const tags = options?.tags ?? {};
|
||||
const labels = options?.labels ?? [];
|
||||
|
||||
// Check for tag/label conflicts
|
||||
const tagKeys = Object.keys(tags);
|
||||
for (const key of tagKeys) {
|
||||
if (labels.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO variables (name, schema, value, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(name, schema, value, now, now);
|
||||
|
||||
// Insert tags
|
||||
if (tagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, val] of Object.entries(tags)) {
|
||||
tagStmt.run(name, schema, key, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert labels
|
||||
if (labels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const labelName of labels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
created: now,
|
||||
updated: now,
|
||||
tags,
|
||||
labels: [...labels],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a variable by name, optionally with schema
|
||||
*/
|
||||
/**
|
||||
* Get a variable by name and schema
|
||||
* @param name - Variable name
|
||||
* @param schema - Schema hash (required)
|
||||
* @returns Variable if found, null otherwise
|
||||
*/
|
||||
get(name: string, schema: Hash): Variable | null {
|
||||
// Precise match with schema
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT name, schema, value, created, updated
|
||||
FROM variables
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
const row = stmt.get(name, schema) as
|
||||
| {
|
||||
name: string;
|
||||
schema: string;
|
||||
value: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
if (row === undefined || row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = this.loadTags(row.name, row.schema);
|
||||
const labels = this.loadLabels(row.name, row.schema);
|
||||
|
||||
return {
|
||||
name: row.name,
|
||||
schema: row.schema,
|
||||
value: row.value,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
tags,
|
||||
labels,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a variable's value (with schema validation)
|
||||
*/
|
||||
update(name: string, schema: Hash, value: string): Variable {
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const newSchema = this.extractSchema(value);
|
||||
if (newSchema !== existing.schema) {
|
||||
throw new SchemaMismatchError(existing.schema, newSchema);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE variables
|
||||
SET value = ?, updated = ?
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
stmt.run(value, now, name, schema);
|
||||
|
||||
return {
|
||||
...existing,
|
||||
value,
|
||||
updated: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a variable (or all variants if schema omitted)
|
||||
*/
|
||||
remove(name: string): Variable[];
|
||||
remove(name: string, schema: Hash): Variable;
|
||||
remove(name: string, schema?: Hash): Variable | Variable[] {
|
||||
if (schema !== undefined) {
|
||||
// Remove specific (name, schema) variant
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM variables WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
stmt.run(name, schema);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Remove all schema variants for this name
|
||||
const variants = this.list({ exactName: name });
|
||||
|
||||
if (variants.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM variables WHERE name = ?
|
||||
`);
|
||||
|
||||
stmt.run(name);
|
||||
|
||||
return variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* List variables with optional filters
|
||||
*/
|
||||
list(options?: {
|
||||
namePrefix?: string;
|
||||
exactName?: string;
|
||||
schema?: Hash;
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
}): Variable[] {
|
||||
// Validate mutually exclusive options
|
||||
if (options?.namePrefix !== undefined && options?.exactName !== undefined) {
|
||||
throw new Error(
|
||||
"namePrefix and exactName are mutually exclusive - cannot specify both",
|
||||
);
|
||||
}
|
||||
|
||||
const namePrefix = options?.namePrefix ?? "";
|
||||
const exactName = options?.exactName;
|
||||
const schema = options?.schema;
|
||||
const filterTags = options?.tags ?? {};
|
||||
const filterLabels = options?.labels ?? [];
|
||||
|
||||
// Build query with filters
|
||||
let query = `
|
||||
SELECT DISTINCT v.name, v.schema, v.value, v.created, v.updated
|
||||
FROM variables v
|
||||
`;
|
||||
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
// Tag filters (AND logic)
|
||||
const tagKeys = Object.keys(filterTags);
|
||||
for (let i = 0; i < tagKeys.length; i++) {
|
||||
const key = tagKeys[i] as string;
|
||||
const value = filterTags[key] as string;
|
||||
query += `
|
||||
INNER JOIN variable_tags t${i} ON v.name = t${i}.variable_name
|
||||
AND v.schema = t${i}.variable_schema
|
||||
AND t${i}.key = ? AND t${i}.value = ?
|
||||
`;
|
||||
params.push(key, value);
|
||||
}
|
||||
|
||||
// Label filters (AND logic)
|
||||
for (let i = 0; i < filterLabels.length; i++) {
|
||||
const label = filterLabels[i] as string;
|
||||
query += `
|
||||
INNER JOIN variable_labels l${i} ON v.name = l${i}.variable_name
|
||||
AND v.schema = l${i}.variable_schema
|
||||
AND l${i}.name = ?
|
||||
`;
|
||||
params.push(label);
|
||||
}
|
||||
|
||||
// WHERE clause for name filters and schema
|
||||
const whereClauses: string[] = [];
|
||||
|
||||
if (exactName !== undefined) {
|
||||
whereClauses.push("v.name = ?");
|
||||
params.push(exactName);
|
||||
} else if (namePrefix !== "") {
|
||||
whereClauses.push("v.name LIKE ? || '%'");
|
||||
params.push(namePrefix);
|
||||
}
|
||||
|
||||
if (schema !== undefined) {
|
||||
whereClauses.push("v.schema = ?");
|
||||
params.push(schema);
|
||||
}
|
||||
|
||||
if (whereClauses.length > 0) {
|
||||
query += ` WHERE ${whereClauses.join(" AND ")}`;
|
||||
}
|
||||
|
||||
query += " ORDER BY v.created ASC";
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...params) as Array<{
|
||||
name: string;
|
||||
schema: string;
|
||||
value: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
name: row.name,
|
||||
schema: row.schema,
|
||||
value: row.value,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
tags: this.loadTags(row.name, row.schema),
|
||||
labels: this.loadLabels(row.name, row.schema),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/update/delete tags and labels
|
||||
*/
|
||||
tag(
|
||||
name: string,
|
||||
schema: Hash,
|
||||
operations: {
|
||||
add?: Record<string, string>; // tags to add/update
|
||||
addLabels?: string[]; // labels to add
|
||||
delete?: string[]; // tag keys or label names to delete
|
||||
},
|
||||
): Variable {
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const addTags = operations.add ?? {};
|
||||
const addLabels = operations.addLabels ?? [];
|
||||
const deleteNames = operations.delete ?? [];
|
||||
|
||||
// Check for conflicts between tags and labels
|
||||
const newTagKeys = Object.keys(addTags);
|
||||
for (const key of newTagKeys) {
|
||||
// Check if this key is being added as a label in the same operation
|
||||
if (addLabels.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
// Check if this key already exists as a label (and not being deleted)
|
||||
if (existing.labels.includes(key) && !deleteNames.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
}
|
||||
|
||||
for (const labelName of addLabels) {
|
||||
// Check if this name is being added as a tag in the same operation
|
||||
if (newTagKeys.includes(labelName)) {
|
||||
throw new TagLabelConflictError(labelName, "tag", "label");
|
||||
}
|
||||
// Check if this name already exists as a tag key (and not being deleted)
|
||||
if (
|
||||
existing.tags[labelName] !== undefined &&
|
||||
!deleteNames.includes(labelName)
|
||||
) {
|
||||
throw new TagLabelConflictError(labelName, "tag", "label");
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
try {
|
||||
// Update timestamp
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE variables SET updated = ? WHERE name = ? AND schema = ?
|
||||
`);
|
||||
updateStmt.run(now, name, schema);
|
||||
|
||||
// Delete tags and labels
|
||||
if (deleteNames.length > 0) {
|
||||
const deleteTagStmt = this.db.prepare(`
|
||||
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ? AND key = ?
|
||||
`);
|
||||
const deleteLabelStmt = this.db.prepare(`
|
||||
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ? AND name = ?
|
||||
`);
|
||||
for (const deleteName of deleteNames) {
|
||||
deleteTagStmt.run(name, schema, deleteName);
|
||||
deleteLabelStmt.run(name, schema, deleteName);
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update tags
|
||||
if (newTagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, value] of Object.entries(addTags)) {
|
||||
tagStmt.run(name, schema, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels (with conflict handling)
|
||||
if (addLabels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const labelName of addLabels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Return updated variable
|
||||
const updated = this.get(name, schema);
|
||||
if (updated === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a variable store
|
||||
*/
|
||||
export function createVariableStore(
|
||||
dbPath: string,
|
||||
casStore: Store,
|
||||
): VariableStore {
|
||||
return new VariableStore(dbPath, casStore);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Hash } from "./types.js";
|
||||
|
||||
/**
|
||||
* Variable: mutable binding to an immutable CAS node
|
||||
* Identified by composite key (name, schema)
|
||||
*/
|
||||
export type Variable = {
|
||||
name: string; // variable name (unique per schema)
|
||||
schema: Hash; // schema hash (part of composite key)
|
||||
value: Hash; // CAS node hash
|
||||
created: number; // epoch ms
|
||||
updated: number; // epoch ms
|
||||
tags: Record<string, string>; // key-value pairs
|
||||
labels: string[]; // bare identifiers
|
||||
};
|
||||
@@ -0,0 +1,734 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "../src/bootstrap.js";
|
||||
import { MemStore } from "../src/mem-store.js";
|
||||
import type { JSONSchema } from "../src/schema.js";
|
||||
import {
|
||||
getSchema,
|
||||
putSchema,
|
||||
refs,
|
||||
SchemaValidationError,
|
||||
validate,
|
||||
walk,
|
||||
} from "../src/schema.js";
|
||||
import type { CasNode } from "../src/types.js";
|
||||
|
||||
describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
test("1.1: Meta-schema is a valid JSON Schema", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
expect(typeof metaNode?.payload).toBe("object");
|
||||
expect(metaNode?.payload).toHaveProperty("type");
|
||||
});
|
||||
|
||||
test("1.2: Meta-schema self-validates", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
expect(validate(store, metaNode as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("1.3: Meta-schema defines all supported keywords", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
const properties =
|
||||
(metaSchema?.properties as Record<string, unknown>) || {};
|
||||
|
||||
// Check that all supported keywords are defined
|
||||
expect(properties).toHaveProperty("type");
|
||||
expect(properties).toHaveProperty("properties");
|
||||
expect(properties).toHaveProperty("required");
|
||||
expect(properties).toHaveProperty("additionalProperties");
|
||||
expect(properties).toHaveProperty("anyOf");
|
||||
expect(properties).toHaveProperty("items");
|
||||
expect(properties).toHaveProperty("format");
|
||||
expect(properties).toHaveProperty("title");
|
||||
expect(properties).toHaveProperty("enum");
|
||||
expect(properties).toHaveProperty("const");
|
||||
expect(properties).toHaveProperty("description");
|
||||
});
|
||||
|
||||
test("1.4: Meta-schema does not include unsupported keywords", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
const properties =
|
||||
(metaSchema?.properties as Record<string, unknown>) || {};
|
||||
|
||||
// Unsupported keywords should not be in properties
|
||||
expect(properties).not.toHaveProperty("$ref");
|
||||
expect(properties).not.toHaveProperty("$id");
|
||||
expect(properties).not.toHaveProperty("$defs");
|
||||
expect(properties).not.toHaveProperty("allOf");
|
||||
expect(properties).not.toHaveProperty("not");
|
||||
});
|
||||
|
||||
test("1.5: Meta-schema node type equals its own hash", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
expect(metaNode?.type).toBe(metaHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
test("2.1: Accept minimal valid schema (empty object)", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.2: Accept schema with type constraint", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, { type: "string" });
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.3: Accept schema with properties", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.4: Accept schema with required fields", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: { id: { type: "string" } },
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.5: Accept schema with additionalProperties = false", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.6: Accept schema with additionalProperties = schema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.7: Accept schema with anyOf", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.7b: Accept schema with oneOf", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
oneOf: [
|
||||
{ properties: { status: { const: "ready" } }, required: ["status"] },
|
||||
{ properties: { status: { const: "failed" } }, required: ["status"] },
|
||||
],
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.8: Accept schema with array items", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.9: Accept schema with format constraint", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
format: "cas_ref",
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.10: Accept schema with enum", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
enum: ["red", "green", "blue"],
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.11: Accept schema with const", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, { const: "FIXED_VALUE" });
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.12: Accept schema with title and description", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
title: "User Name",
|
||||
description: "The user's full name",
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.13: Accept complex nested schema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["type", "payload"],
|
||||
properties: {
|
||||
type: { type: "string", format: "cas_ref" },
|
||||
payload: {
|
||||
anyOf: [{ type: "object" }, { type: "null" }],
|
||||
},
|
||||
refs: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
test("3.1: Reject schema with invalid type value", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(async () => await putSchema(store, { type: "garbage" })).toThrow();
|
||||
});
|
||||
|
||||
test("3.2: Reject schema with type as number", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: 123 } as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.3: Reject schema with properties not an object", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
properties: "not-an-object",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.4: Reject schema with required not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
required: "name",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.5: Reject schema with required containing non-strings", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name", 123, true],
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.6: Reject schema with additionalProperties as string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: "yes",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.7: Reject schema with anyOf not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
anyOf: { type: "string" },
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.8: Reject schema with empty anyOf array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(async () => await putSchema(store, { anyOf: [] })).toThrow();
|
||||
});
|
||||
|
||||
test("3.9: Reject schema with items not an object", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "array",
|
||||
items: "string",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.10: Reject schema with format not a string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
format: 123,
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.11: Reject schema with enum not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
enum: "red",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.12: Reject schema with empty enum array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () => await putSchema(store, { type: "string", enum: [] }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.13: Reject schema with title not a string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
title: 123,
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.14: Reject schema with description not a string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
description: ["not a string"],
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.15: Reject schema with unsupported $ref keyword", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
$ref: "#/definitions/user",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.16: Reject completely invalid data (non-object)", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, "not-a-schema" as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.17: Reject nested invalid schema in properties", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "invalid-type" },
|
||||
},
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 4: Error Messages and Debugging", () => {
|
||||
test("4.1: Error includes schema validation details", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
try {
|
||||
await putSchema(store, { type: 123 } as unknown as JSONSchema);
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SchemaValidationError);
|
||||
expect((error as Error).message).toContain("Invalid schema");
|
||||
}
|
||||
});
|
||||
|
||||
test("4.2: Error distinguishes schema validation from data validation", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
try {
|
||||
await putSchema(store, { type: "invalid-type" } as unknown as JSONSchema);
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SchemaValidationError);
|
||||
expect((error as Error).message.toLowerCase()).toContain("schema");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
test("5.1: Bootstrap hash changes (breaking change)", async () => {
|
||||
// This is a documentation test - the old hash was different
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const newMetaHash = builtinSchemas["@schema"] ?? "";
|
||||
|
||||
// The new hash should be different from the old system metadata hash
|
||||
// We just verify it's a valid hash format
|
||||
expect(newMetaHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("5.2: Existing tests compatibility", async () => {
|
||||
// This test ensures our changes don't break existing valid schema usage
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// This is the kind of schema that existed before
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(schemaHash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("5.3: Data nodes with valid schemas still validate", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const dataNode = store.get(await store.put(schemaHash, { name: "test" }));
|
||||
|
||||
expect(dataNode).not.toBeNull();
|
||||
expect(validate(store, dataNode as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("5.4: Invalid data still fails validation", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const dataNode = store.get(
|
||||
await store.put(schemaHash, { name: 123 }), // wrong type
|
||||
);
|
||||
|
||||
expect(dataNode).not.toBeNull();
|
||||
expect(validate(store, dataNode as CasNode)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
test("6.1: getSchema works with validated schemas", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const originalSchema = { type: "string", title: "Test" };
|
||||
const schemaHash = await putSchema(store, originalSchema);
|
||||
const retrieved = getSchema(store, schemaHash);
|
||||
|
||||
expect(retrieved).toEqual(originalSchema);
|
||||
});
|
||||
|
||||
test("6.2: validate() works with schemas validated by meta-schema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, { type: "number" });
|
||||
const validNode = store.get(await store.put(schemaHash, 42));
|
||||
const invalidNode = store.get(await store.put(schemaHash, "not a number"));
|
||||
|
||||
expect(validate(store, validNode as CasNode)).toBe(true);
|
||||
expect(validate(store, invalidNode as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("6.3: refs() works with validated schemas containing cas_ref", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
|
||||
const refHash = "0000000000001";
|
||||
const dataNode = store.get(await store.put(schemaHash, { ref: refHash }));
|
||||
|
||||
const extractedRefs = refs(store, dataNode as CasNode);
|
||||
expect(extractedRefs).toContain(refHash);
|
||||
});
|
||||
|
||||
test("6.4: walk() works with graphs using validated schemas", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
next: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const node2Hash = await store.put(schemaHash, { next: null });
|
||||
const node1Hash = await store.put(schemaHash, { next: node2Hash });
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, node1Hash, (hash) => visited.push(hash));
|
||||
|
||||
expect(visited).toContain(node1Hash);
|
||||
expect(visited).toContain(node2Hash);
|
||||
});
|
||||
|
||||
test("6.5: Idempotency preserved for putSchema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schema = { type: "string", title: "Test" };
|
||||
const hash1 = await putSchema(store, schema);
|
||||
const hash2 = await putSchema(store, schema);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||
test("7.1: Meta-schema allows recursive schema definitions", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@schema"] ?? "";
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
// The meta-schema should have properties that can contain schemas
|
||||
const properties =
|
||||
(metaSchema?.properties as Record<string, unknown>) || {};
|
||||
expect(properties).toHaveProperty("properties");
|
||||
});
|
||||
|
||||
test("7.2: Meta-schema restricts additionalProperties", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Schema with unknown keyword should be rejected if meta-schema is strict
|
||||
try {
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
unknownKeyword: "value",
|
||||
} as unknown as JSONSchema);
|
||||
// If we get here, meta-schema allows additional properties
|
||||
// This is acceptable behavior
|
||||
} catch (error) {
|
||||
// If it throws, meta-schema is strict about additionalProperties
|
||||
expect(error).toBeInstanceOf(SchemaValidationError);
|
||||
}
|
||||
});
|
||||
|
||||
test("7.3: Meta-schema validates type as string OR array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Single string type
|
||||
const hash1 = await putSchema(store, { type: "string" });
|
||||
expect(hash1).toBeTruthy();
|
||||
|
||||
// Array of types
|
||||
const hash2 = await putSchema(store, {
|
||||
type: ["string", "null"],
|
||||
} as unknown as JSONSchema);
|
||||
expect(hash2).toBeTruthy();
|
||||
|
||||
// Invalid type (number)
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: 123 } as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 8: Performance and Edge Cases", () => {
|
||||
test("8.1: Validation performance is acceptable", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const complexSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
level1: {
|
||||
type: "object",
|
||||
properties: {
|
||||
level2: {
|
||||
type: "object",
|
||||
properties: {
|
||||
level3: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await putSchema(store, complexSchema);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should complete in reasonable time (< 100ms for 100 validations)
|
||||
expect(duration).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test("8.2: Large schemas are handled correctly", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const largeSchema: Record<string, unknown> = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
};
|
||||
|
||||
// Create a schema with 100 properties
|
||||
const props = largeSchema.properties as Record<string, unknown>;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
props[`prop${i}`] = { type: "string" };
|
||||
}
|
||||
|
||||
const hash = await putSchema(store, largeSchema);
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("8.3: Deeply nested schemas validate correctly", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Build a 5-level deep schema
|
||||
let schema: Record<string, unknown> = { type: "string" };
|
||||
for (let i = 0; i < 5; i++) {
|
||||
schema = {
|
||||
type: "object",
|
||||
properties: { nested: schema },
|
||||
};
|
||||
}
|
||||
|
||||
const hash = await putSchema(store, schema);
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("8.4: Circular-like schemas don't cause infinite loops", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Schema where additionalProperties has same structure as parent
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const hash = await putSchema(store, schema);
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,5 +4,6 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"noImplicitOverride": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@uncaged/json-cas": ["./packages/json-cas/src/index.ts"],
|
||||
"@uncaged/json-cas-fs": ["./packages/json-cas-fs/src/index.ts"]
|
||||
},
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
Reference in New Issue
Block a user