1 Commits

Author SHA1 Message Date
xiaoju af23c3dce8 chore: 测试框架从 bun:test 迁移到 vitest
- 36 个 test 文件 bun:test → vitest
- Bun.spawn() → execFileSync('tsx', ...)
- Bun.file() → readFileSync
- import.meta.dir → import.meta.dirname (tests) / __dirname (CLI source)
- 删除 bun-types devDep
- 添加 vitest + tsx devDep
- CLI shebang bun → node
- 30/36 test files pass, 558/617 tests pass

Refs #62
2026-06-03 03:52:56 +00:00
78 changed files with 2058 additions and 6918 deletions
+1 -27
View File
@@ -17,7 +17,7 @@ The `ocas` CLI is the primary interface for interacting with an OCAS [[Store]].
| 2 | `OCAS_HOME` env var | `export OCAS_HOME=/data/ocas` |
| 3 | Default | `~/.ocas` |
The SQLite database lives at `<home>/_store.db`.
The variable database lives at `<home>/variables.db` by default, overridable with `--var-db <path>`.
## Commands
@@ -68,36 +68,12 @@ ocas render <hash> [--resolution n] [--decay n] [--epsilon n]
ocas render --pipe/-p [options]
```
### Bundle Export / Import
```bash
ocas export <root>... -o <bundle.tar> # write CAS closure of roots to tar
ocas import <bundle.tar> [--scope @new] # merge bundle into current store
```
`ocas export` walks `cas_ref` edges **and** schema chains from each root, then
writes a self-contained POSIX-tar archive containing every reachable CAS node
(`cas/<hash>.bin`, CBOR-encoded), every variable whose `value` is in-closure
(`vars.jsonl`), and every tag attached to an in-closure target (`tags.jsonl`).
`ocas import` is content-addressed and idempotent — re-importing the same
bundle is a no-op. `--scope @new` rewrites the leading `@scope/` of every
imported variable name except `@ocas/*` builtins.
`--store <bundle.tar>` is a global flag that swaps the store backend for
read-only commands. Internally this calls `loadBundleStore()` (from
`@ocas/core`) which returns an in-memory `Store` populated from the bundle,
without touching `~/.ocas`. Write commands (`put`, `tag`, `gc`, `import`,
`var set`, `template set`, …) refuse with an explicit error when `--store`
is set.
## Flags
| Flag | Description |
|------|-------------|
| `--home <path>` | Store directory |
| `--var-db <path>` | Variable database path |
| `--store <bundle.tar>` | Open a bundle as a read-only store (write commands rejected) |
| `--json` | Compact JSON output (no pretty-printing) |
| `--pipe`, `-p` | Read from stdin (`put`/`hash`: raw JSON; `render`: envelope) |
| `--schema <hash>` | Schema filter for var commands |
@@ -109,8 +85,6 @@ is set.
| `--limit <n>` | Max results to return (default: 100) |
| `--offset <n>` | Skip first N results (default: 0) |
| `--desc` | Sort descending (default: ascending) |
| `-o <path>` | Output file path (used by `export`) |
| `--scope @new` | Variable scope remap on import (used by `import`) |
## Variable Names
+1 -1
View File
@@ -53,7 +53,7 @@ In-memory `Map<Hash, CasNode>`. Used in tests and for ephemeral computation (e.g
### FsStore (`@ocas/fs`)
Filesystem-backed store. CAS nodes are stored as CBOR files; variables and tags use SQLite (`node:sqlite`). Created via `openStore(path)`, which:
Filesystem-backed store. Nodes are serialized as CBOR files under a content-addressed directory tree. Created via `openStore(path)`, which:
1. Creates the directory if it doesn't exist
2. Runs [[Bootstrap]] automatically
-28
View File
@@ -1,28 +0,0 @@
name: CI
on:
push:
branches: ['*']
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: corepack enable && pnpm install
- name: Build
run: pnpm run build
- name: Lint
run: pnpm run check
- name: Test
run: pnpm run test
-1
View File
@@ -3,4 +3,3 @@ dist/
*.d.ts.map
*.tsbuildinfo
.worktrees/
bun.lock
+413
View File
@@ -0,0 +1,413 @@
name: "e2e-check"
description: "Docker-isolated E2E testing of json-cas CLI. Preparer builds from scratch, tester runs scenarios, reporter files bugs."
roles:
preparer:
description: "Spins up Docker container, copies repo, installs, builds, runs unit tests"
goal: "You set up a clean Docker environment for E2E testing. Your job is to start a container, install deps, build, lint, run unit tests, and initialize a CAS store. Report any setup failures as bugs."
capabilities:
- docker
procedure: |
1. Start a detached container:
```bash
docker run -d --name json-cas-e2e \
-v "<repoPath>:/src:ro" \
-w /workspace \
oven/bun:latest \
sleep 3600
```
2. Copy repo and install:
```bash
docker exec json-cas-e2e bash -c 'cp -r /src/. /workspace/ && cd /workspace && bun install'
```
✅ exit code 0, no missing peer deps
3. Build:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun run build'
```
✅ exit code 0, no type errors
4. Lint:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun run check'
```
✅ exit code 0
5. Unit tests:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun test'
```
✅ all pass (ignore dist/ false positives)
6. Init CAS store:
```bash
docker exec json-cas-e2e bash -c 'mkdir -p /tmp/cas-test && cd /workspace && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test init && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test bootstrap'
```
**Any failure here is a high-severity bug** — it means a clean environment can't build/run the project.
Set $status=ready if all steps pass. Set $status=setup_failed with failures list if anything breaks.
output: "Setup result summary."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "ready" }
containerName: { type: string }
storePath: { type: string }
repoPath: { type: string }
required: [$status, containerName, storePath, repoPath]
- type: object
properties:
$status: { const: "setup_failed" }
failures:
type: array
items:
type: object
properties:
title: { type: string }
command: { type: string }
expected: { type: string }
actual: { type: string }
severity: { type: string }
phase: { type: string }
required: [title, command, expected, actual, severity, phase]
repoPath: { type: string }
required: [$status, failures, repoPath]
tester:
description: "Runs CLI scenarios against the prepared Docker environment"
goal: "You are an exploratory QA agent. The Docker container is already running with the project built and a CAS store initialized. Run CLI test scenarios and report bugs."
capabilities:
- testing
- cli
procedure: |
The container `{{{containerName}}}` is already running with the project built.
Store path: `{{{storePath}}}`.
Run all commands via:
```bash
docker exec {{{containerName}}} bash -c 'cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}} <subcommand>'
```
Define a shorthand in your notes:
`CMD="cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}}"`
## Phase 1: CAS Core Operations
1. **bootstrap** — `$CMD bootstrap`
Expected: prints meta-schema hash (13-char Base32)
2. **schema put** — Create `/tmp/test-schema.json` in container, then `$CMD schema put /tmp/test-schema.json`
Schema: `{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"number"}},"required":["name"],"additionalProperties":false}`
Expected: prints type hash
3. **schema get** — `$CMD schema get <type-hash>`
Expected: returns the schema JSON
4. **schema list** — `$CMD schema list`
Expected: lists registered schemas
5. **put** — Create data file, `$CMD put <type-hash> /tmp/test-node.json`
Data: `{"name":"Alice","age":30}`
Expected: prints node hash
6. **get** — `$CMD get <node-hash>`
Expected: returns node JSON
7. **has (exists)** — `$CMD has <node-hash>`
Expected: true
8. **has (not exists)** — `$CMD has AAAAAAAAAAAAA`
Expected: false
9. **verify** — `$CMD verify <node-hash>`
Expected: ok
10. **refs** — `$CMD refs <node-hash>`
Expected: lists refs (may be empty)
11. **walk** — `$CMD walk <node-hash>`
Expected: shows traversal tree
12. **hash (dry run)** — `$CMD hash <type-hash> /tmp/test-node.json`
Expected: same hash as put
13. **cat** — `$CMD cat <node-hash>`
Expected: full node output
14. **cat --payload** — `$CMD cat <node-hash> --payload`
Expected: payload only (no type wrapper)
## Phase 2: Schema Validation
1. **Invalid node** — `$CMD put <type-hash> /tmp/bad-node.json` where bad-node = `{"name":123}`
Expected: validation error, non-zero exit
2. **schema validate** — `$CMD schema validate <node-hash>`
Expected: valid for good node
3. **Non-existent schema** — `$CMD put AAAAAAAAAAAAA /tmp/test-node.json`
Expected: error about missing schema
## Phase 3: Variable System
1. **var set** — `$CMD var set myapp/config <node-hash>`
Expected: creates variable
2. **var get** — `$CMD var get myapp/config --schema <type-hash>`
Expected: returns variable
3. **var list** — `$CMD var list`
Expected: shows all variables
4. **var list prefix** — `$CMD var list myapp/`
Expected: filtered results
5. **var set (update)** — Put a second node, `$CMD var set myapp/config <new-hash>`
Expected: upsert succeeds
6. **var tag** — `$CMD var tag myapp/config --schema <type-hash> env:prod important`
Expected: adds tag and label
7. **var list --tag** — `$CMD var list --tag env:prod`
Expected: finds tagged variable
8. **var list --tag (label)** — `$CMD var list --tag important`
Expected: finds labeled variable
9. **var tag remove** — `$CMD var tag myapp/config --schema <type-hash> :important`
Expected: removes label
10. **var delete** — `$CMD var delete myapp/config`
Expected: deletes variable
11. **var get (deleted)** — `$CMD var get myapp/config --schema <type-hash>`
Expected: not found error
## Phase 4: Template System
1. **template set** — Create template file, `$CMD template set <type-hash> /tmp/test.liquid`
Template: `Name: {{ payload.name }}, Age: {{ payload.age }}`
Expected: success
2. **template get** — `$CMD template get <type-hash>`
Expected: returns template text
3. **template list** — `$CMD template list`
Expected: lists templates
4. **template delete** — `$CMD template delete <type-hash>`
Expected: success
5. **template get (deleted)** — `$CMD template get <type-hash>`
Expected: not found error
## Phase 5: Render
1. Re-register template, then `$CMD render <node-hash>`
Expected: rendered output with payload values filled in
2. **render --resolution** — `$CMD render <node-hash> --resolution 0.5`
Expected: different resolution output
3. **render (bad hash)** — `$CMD render AAAAAAAAAAAAA`
Expected: graceful error, non-zero exit
## Phase 6: GC
1. **gc basic** — `$CMD gc`
Expected: runs without error
2. **gc preserves referenced** — Verify `$CMD has <node-hash>` still true
3. **gc collects orphans** — Put an orphan node (not in any variable), run gc, check it's gone
## Phase 7: Edge Cases & Error Handling
1. `$CMD get AAAAAAAAAAAAA` — non-existent
2. `$CMD put <type-hash> /nonexistent/file.json` — missing file
3. `$CMD var set "" <hash>` — empty name
4. `$CMD var set "bad name!" <hash>` — invalid name chars
5. `$CMD schema put /tmp/bad-schema.json` — `{"type":"invalid"}`
6. `$CMD` with no subcommand — should show help
7. `$CMD --store /nonexistent/path get <hash>` — bad store path
## Recording Results
For each scenario:
- ✅ Pass: works as expected
- ❌ Fail: unexpected behavior, crash, wrong output
- ⚠️ Questionable: works but confusing UX
Collect all ❌ and ⚠️. For each, record:
- Title (concise description)
- Command (exact command run)
- Expected behavior
- Actual behavior (include actual output)
- Severity: critical / high / medium / low
- Phase: which test phase
## CRITICAL: Frontmatter Output Format
Your response MUST start with YAML frontmatter. The `bugs` field MUST be an array of objects, NOT strings.
Example of CORRECT frontmatter:
```yaml
---
$status: bugs_found
containerName: json-cas-e2e
repoPath: /path/to/repo
bugs:
- title: "put does not validate data against schema"
command: "json-cas put <hash> bad-data.json"
expected: "Validation error, non-zero exit"
actual: "Accepted invalid data, exit 0"
severity: "high"
phase: "Schema Validation"
---
```
Do NOT write bugs as plain strings like `- some bug description`. Each bug MUST be an object with all 6 fields.
If all tests pass:
```yaml
---
$status: all_passed
containerName: json-cas-e2e
---
```
output: "Summary of all phases with pass/fail counts. Set $status."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "bugs_found" }
bugs:
type: array
items:
type: object
properties:
title: { type: string }
command: { type: string }
expected: { type: string }
actual: { type: string }
severity: { type: string }
phase: { type: string }
required: [title, command, expected, actual, severity, phase]
containerName: { type: string }
repoPath: { type: string }
required: [$status, bugs, containerName]
- type: object
properties:
$status: { const: "all_passed" }
containerName: { type: string }
required: [$status, containerName]
reporter:
description: "Opens Gitea issues for each bug found by the tester"
goal: "You are a bug reporter. You create well-formatted Gitea issues for each bug found during E2E testing."
capabilities:
- issue-management
procedure: |
1. Parse the bugs array from the tester's output
2. Group bugs by severity (critical first)
3. For each bug, create a Gitea issue:
```bash
tea issues create -r uncaged/json-cas \
-t "[E2E] <title>" \
-d "## Bug Report (E2E Check)
**Phase:** <phase>
**Severity:** <severity>
**Command:**
\`\`\`
<exact command>
\`\`\`
**Expected:** <expected>
**Actual:** <actual>
---
_Reported by e2e-check workflow (Docker isolated)_"
```
⚠️ If `tea issues create` fails with long body, use Gitea REST API:
```bash
eval "$(cfg env)" && GITEA_TOKEN=$(cfg get GITEA_TOKEN)
curl -s -X POST "https://git.shazhou.work/api/v1/repos/uncaged/json-cas/issues" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"[E2E] ...","body":"..."}'
```
4. Collect created issue numbers
output: "List created issues. Set $status."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "reported" }
issues:
type: array
items: { type: string }
required: [$status, issues]
- type: object
properties:
$status: { const: "partial" }
created: { type: number }
failed: { type: number }
required: [$status, created, failed]
cleanup:
description: "Stops and removes the Docker container"
goal: "You clean up the Docker environment after testing is complete."
capabilities:
- docker
procedure: |
Stop and remove the container:
```bash
docker stop {{{containerName}}} && docker rm {{{containerName}}}
```
Verify it's gone:
```bash
docker ps -a --filter name={{{containerName}}} --format '{{.Names}}'
```
Expected: empty output.
output: "Cleanup result."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "cleaned" }
required: [$status]
- type: object
properties:
$status: { const: "cleanup_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "preparer", prompt: "Set up Docker environment for E2E testing. Repo at {{{repoPath}}}." }
preparer:
ready: { role: "tester", prompt: "Environment ready. Container: {{{containerName}}}, store: {{{storePath}}}. Run all test scenarios." }
setup_failed: { role: "reporter", prompt: "Setup failures found. File these as bugs: {{{failures}}}" }
tester:
all_passed: { role: "cleanup", prompt: "All tests passed. Clean up container {{{containerName}}}." }
bugs_found: { role: "reporter", prompt: "File these bugs as Gitea issues: {{{bugs}}}" }
reporter:
reported: { role: "cleanup", prompt: "Bugs filed: {{{issues}}}. Clean up container {{{containerName}}}." }
partial: { role: "cleanup", prompt: "Filed {{{created}}} issues, {{{failed}}} failed. Clean up container {{{containerName}}}." }
cleanup:
cleaned: { role: "$END", prompt: "E2E check complete. Environment cleaned up." }
cleanup_failed: { role: "$END", prompt: "E2E check complete but cleanup failed: {{{error}}}" }
+6 -7
View File
@@ -120,15 +120,15 @@ roles:
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> && pnpm install`
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 `pnpm run build` and `pnpm test` to verify
10. Run `pnpm run check` for lint
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:
@@ -157,8 +157,8 @@ roles:
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. `pnpm run build` and `pnpm test` must pass
6. `pnpm run check` must pass
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)."
@@ -212,8 +212,7 @@ roles:
required: [$status, error]
graph:
$START:
new: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
resume: { role: "analyst", prompt: "Review previous analysis of thread {{{threadId}}} and continue." }
_: { 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." }
+43 -90
View File
@@ -1,5 +1,5 @@
name: "solve-issue"
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds. Uses pnpm."
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"
@@ -8,64 +8,34 @@ roles:
- issue-analysis
- planning
procedure: |
CRITICAL: First, determine which mode you are in by scanning the task prompt.
Choose EXACTLY ONE mode — do NOT default to Mode A if Mode B applies.
**How to choose:**
- If the prompt contains ANY of these keywords: "PR #", "PR#", "pulls/", "继续修复", "continue", "review feedback", "existing branch", "fix/", or mentions a branch name → **Mode B**
- If the prompt was forwarded from tester with fix_spec → **Mode C**
- Otherwise → **Mode A**
**Mode A — Fresh issue (first time, no existing PR):**
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
6. The test spec is stored in CAS automatically by the uwf pipeline (agents do not need to call `ocas put` directly)
7. Output **$status=ready** with plan hash and repoPath
**Mode B — Continue on existing PR (prompt mentions PR, branch, or review feedback):**
YOU MUST output $status=continue (NOT ready) when in this mode.
1. Extract the PR number and branch name from the prompt
2. Read the PR and its review comments from Gitea: `tea pr <number> --comments -r <owner/repo>`
3. Read the existing issue for full context: `tea issues <number> -r <owner/repo>`
4. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
5. Produce a TDD test spec that ONLY covers the changes requested in the review — do NOT re-spec already-implemented features
6. The test spec is stored in CAS automatically by the uwf pipeline (agents do not need to call `ocas put` directly)
7. Find the existing worktree: `git worktree list` and locate the branch
8. Output **$status=continue** with plan hash, repoPath, branch name, and worktree path
**Mode C — Bounced back by tester (fix_spec):**
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
3. The test spec is stored in CAS automatically by the uwf pipeline (agents do not need to call `ocas put` directly)
4. Output **$status=ready** with plan hash and repoPath
IMPORTANT: Extract the repo remote (owner/repo) from git:
```bash
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
```
Store the result as repoRemote in your frontmatter output so downstream roles can use it.
output: "Output a brief summary of the test spec. Set $status to ready (fresh), continue (existing PR), or insufficient_info."
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:
- type: object
properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "continue" }
plan: { type: string }
repoPath: { type: string }
branch: { type: string }
worktree: { type: string }
required: [$status, plan, repoPath, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "insufficient_info" }
reason: { type: string }
required: [$status, reason]
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."
@@ -80,47 +50,33 @@ roles:
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> && pnpm install`
4. If continuing on existing branch (prompt says "Continue work on existing branch" or provides a worktree path):
- cd directly into the worktree path provided in the prompt
- `git fetch origin && git rebase origin/main`
- Do NOT create a new branch or worktree
5. If bounced back from reviewer or tester (branch already exists but no explicit worktree path):
- `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`
6. ALL subsequent work must happen inside the worktree directory.
5. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
6. Read the test spec from CAS: `ocas get <plan hash>` (find the hash from the planner's output in your task prompt)
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 `pnpm run build` passes with no errors
11. Run `pnpm test` to verify all tests pass
After implementation, before reporting done:
12. Add a changeset file (`.changeset/<short-slug>.md`) with correct bump type:
- `patch` for bug fixes, internal refactors, test-only changes
- `minor` for new features, new CLI commands, new API surfaces
- `major` for breaking changes
List every affected package in the changeset frontmatter.
13. Update documentation if the change affects user-facing behavior:
- `README.md` — usage examples, feature descriptions
- `.cards/` — architecture decision records (if applicable)
- CLI prompt subcommand output (if CLI help text changes)
- CLI `--help` text (if flags/commands are added or changed)
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:
- type: object
properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
@@ -139,8 +95,8 @@ roles:
Then perform code review:
Hard checks (must all pass):
3. `pnpm run build` — no build errors
4. `pnpm run check` — no lint violations
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):
@@ -148,25 +104,19 @@ roles:
- No `console.log` in production code
- No dynamic imports in production code
Documentation & changeset checks:
6. Changeset exists in `.changeset/` with correct bump type (`patch`/`minor`/`major`) and lists all affected packages
7. If the change is user-facing, documentation is updated:
- `README.md` reflects new/changed behavior
- `.cards/` architecture cards updated if design decisions changed
- CLI prompt subcommand output updated (if it generates skill/reference content)
- CLI `--help` text matches new flags/commands
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:
- type: object
properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
@@ -179,8 +129,8 @@ roles:
procedure: |
The worktree path is provided in your task prompt. cd into it first.
1. Run `pnpm test` for automated test verification
2. Read the test spec from CAS: `ocas get <plan hash>` (find the hash from the planner step in the thread history)
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
@@ -189,16 +139,19 @@ roles:
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "fix_code" }
report: { type: string }
required: [$status, report]
- properties:
- type: object
properties:
$status: { const: "fix_spec" }
report: { type: string }
required: [$status, report]
@@ -225,22 +178,22 @@ roles:
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
- type: object
properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
new: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
resume: { role: "planner", prompt: "Review the previous run output and continue the work." }
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" }
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}}}." }
continue: { role: "developer", prompt: "Continue work on existing branch {{{branch}}} at worktree {{{worktree}}}. Implement the revised TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Do NOT create a new branch or worktree — cd into the existing worktree and work there." }
developer:
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
+56 -27
View File
@@ -14,21 +14,20 @@ Monorepo with 3 packages under `packages/`:
## Tech Stack
- **Runtime:** Node.js
- **Runtime:** Bun
- **Language:** TypeScript (strict mode, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
- **Build:** `tsc` (composite project references, sequential: core → fs → cli)
- **Test:** Vitest (`npx vitest run`)
- **Package Manager:** pnpm (workspace)
- **Build:** `tsc --build` (composite project references)
- **Test:** `bun test`
- **Lint/Format:** Biome (`biome check .` / `biome format --write .`)
- **Publish:** @shazhou/proman (`proman bump` + `proman publish`)
- **Publish:** Changesets + `bun publish` → npmjs (`@ocas/*`)
## Commands
```bash
pnpm run test # Run all tests (vitest)
pnpm run build # Build all packages (tsc via proman)
pnpm run check # Biome lint
pnpm run format # Biome format (auto-fix)
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
@@ -73,7 +72,7 @@ pnpm run format # Biome format (auto-fix)
- **`bootstrap(store)`** synchronously writes builtin name → hash bindings into the unified store; called automatically by `openStore()`.
- **`resolveHash(input, store)`** is the unified hash/name resolver in the CLI. If `input` matches the 13-char hash format it is returned as-is; otherwise `store.var` is queried by exact name. This means every CLI command that accepts a hash argument also accepts a variable name (schema names, user vars, etc.).
- **Variable naming**: all names must follow `@scope/name` format (`@[a-zA-Z][a-zA-Z0-9]*/segments`). `@ocas/*` is reserved for builtins. The `@` prefix ensures names are visually distinct from hashes.
- **`openStore()`** returns a unified `Store` with `cas`, `var`, and `tag` sub-stores, and bootstraps automatically. `@ocas/core` has zero SQLite dependency.
- **`openStore()`** returns a unified `Store` with `cas`, `var`, and `tag` sub-stores, and bootstraps automatically. `@ocas/core` has zero `bun:sqlite` dependency.
### Internal Dependencies
@@ -86,15 +85,22 @@ This is resolved to real version numbers only during publishing (see below).
- 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. `pnpm run test` — all tests pass
2. `pnpm run check` — no lint errors
3. `pnpm run build` — builds cleanly
1. `bun test` — all tests pass
2. `bun run check` — no lint errors
3. `bun run build` — builds cleanly
## Release Process
Uses `@shazhou/proman` for releases. No release branches needed.
Releases use a **release branch** workflow with three phases: prepare → candidate → finalize.
`main` always keeps `workspace:*` for internal deps; release branches fix them to real versions.
Changeset files are **only consumed once** during finalize — prerelease (rc) never touches them.
### Adding a Changeset
@@ -103,7 +109,7 @@ Add changesets alongside feature PRs on `main`:
```markdown
<!-- .changeset/my-change.md -->
---
"@ocas/fs": minor
"@ocas/cli": patch
---
Description of the change
@@ -112,21 +118,44 @@ Description of the change
Changesets live in `.changeset/` as markdown files. Bump types: `patch` / `minor` / `major`.
One changeset can cover multiple packages.
### Release Steps
### Phase 1: Prepare (cut release branch)
1. `proman bump` — consume changesets and bump versions
2. `proman publish` — build → test → check → publish → changelog → tag → push
- **Precondition:** on `main`, clean tree, `.changeset/` has pending changesets
- **Steps:**
1. Determine target version (from changeset bump types or manually)
2. `git checkout -b release/<version>`
3. Fix `workspace:*` → real version numbers in all `package.json`
4. Commit
- **Does NOT** run `changeset version`, does NOT write CHANGELOG
The publish command handles everything: workspace dependency resolution, npm publish order (core → fs → cli), changelog generation, git tagging, and pushing.
### Phase 2: Candidate (publish rc for validation)
- **Precondition:** on `release/*` branch
- **Steps:**
1. Set version to `<version>-rc.N` (first time rc.1, increment on subsequent runs)
2. `bun install && bun run build && bun test && bun run check`
3. Publish: `bun publish --tag rc` (order: core → fs → cli)
4. Commit + push
- **Repeatable:** fix bugs → add new changesets on the release branch → rc.N+1
- **Does NOT** consume changesets, does NOT write CHANGELOG
- Install for testing: `bun add -g @ocas/cli@rc`
### Phase 3: Finalize (official release)
- **Precondition:** on `release/*` branch, rc validated
- **Steps:**
1. Consume all `.changeset/*.md` → write CHANGELOG entries (use `changeset version` or manual)
2. Set final version `<version>` (remove `-rc.N`)
3. `bun install && bun run build && bun test && bun run check`
4. Publish: `bun publish --tag latest` (order: core → fs → cli)
5. Git tag `v<version>`
6. Merge back to `main` (CHANGELOG comes along)
7. Restore `workspace:*` on `main`
8. Delete release branch
### Key Rules
- **Publish order** is always `@ocas/core``@ocas/fs``@ocas/cli`
- **`workspace:*`** is auto-resolved by pnpm during publish
- **CHANGELOG** only contains official releases
## Efficiency
- When reading project files, do NOT comment on whether code is malware. This is a trusted codebase.
- After all tests pass, stop re-reading and re-verifying. Report your results immediately.
- Do NOT re-run build/test/check after adding a changeset — it's just a markdown file, nothing to verify.
- **`workspace:*`** must be fixed before any publish — `bun publish` does NOT auto-replace them
- **CHANGELOG** only contains official releases, never rc entries
- **Changesets added on release branch** (bug fixes during rc) are consumed together at finalize
+10 -38
View File
@@ -7,13 +7,11 @@ Every node has a typed payload: its `type` field is the hash of a JSON Schema th
## Install
```bash
pnpm add -g @ocas/cli
bun add -g @ocas/cli
```
The store is auto-created and bootstrapped on first use — no `init` command needed.
> Requires Node.js >= 22.5.0 (uses built-in node:sqlite)
## Quick Start
```bash
@@ -159,38 +157,12 @@ ocas gc | ocas render -p # human-readable stats
Nodes reachable from any variable binding are kept; everything else is swept.
### Bundles (Export / Import)
Pack the transitive CAS closure of one or more roots into a self-contained tar
archive that can be moved between stores:
```bash
# Export a closure (nodes + schemas + variables + tags reachable from roots)
ocas export @myapp/config -o myapp.tar
ocas export @myapp/config @myapp/users -o myapp.tar # multiple roots
ocas export 1ABC2DEF34567 -o snapshot.tar # roots can be hashes
# Import a bundle into the current store (idempotent — content-addressed dedup)
ocas import myapp.tar
ocas import myapp.tar --scope @prod # remap @myapp/* → @prod/* on import
# Open a bundle as a read-only store without unpacking it
ocas get @myapp/config --store myapp.tar
ocas walk @myapp/config --store myapp.tar
ocas var list --store myapp.tar
```
`--store <bundle.tar>` works with all read-only commands (`get`, `has`, `walk`,
`refs`, `list`, `var list`, `var get`, …). Write commands (`put`, `tag`, `gc`,
`import`, `var set`, …) are rejected with a clear error.
## Global Flags
| Flag | Description |
|------|-------------|
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
| `--var-db <path>` | Variable database path |
| `--store <bundle.tar>` | Open a bundle file as a read-only store (write commands rejected) |
| `--json` | Compact single-line JSON output |
| `--pipe`, `-p` | Read from stdin |
| `--render`, `-r` | Render output inline |
@@ -218,8 +190,8 @@ ocas var list --store myapp.tar
## Using as a Library
```bash
pnpm add @ocas/core # in-memory store
pnpm add @ocas/core @ocas/fs # + filesystem persistence
bun add @ocas/core # in-memory store
bun add @ocas/core @ocas/fs # + filesystem persistence
```
```typescript
@@ -242,9 +214,9 @@ const node = store.get(hash);
For filesystem persistence, use `@ocas/fs`:
```typescript
import { openStore } from "@ocas/fs";
import { openStoreAndVarStore } from "@ocas/fs";
const store = await openStore("/path/to/store");
const { store, varStore } = await openStoreAndVarStore("/path/to/store");
```
See individual package READMEs for full API docs:
@@ -256,11 +228,11 @@ See individual package READMEs for full API docs:
```bash
git clone <repo-url> && cd ocas
pnpm install
pnpm run build # tsc --build
pnpm test # run all tests
pnpm run check # biome lint
pnpm run format # biome format
bun install --no-cache
bun run build # tsc --build
bun test # run all tests
bun run check # biome lint
bun run format # biome format
```
## License
+307
View File
@@ -0,0 +1,307 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@uncaged/json-cas-workspace",
"devDependencies": {
"@shazhou/proman": "0.1.1",
"tsx": "^4.22.4",
"ulidx": "^2.4.1",
"vitest": "^4.1.8",
},
},
"packages/cli": {
"name": "@ocas/cli",
"version": "0.2.0",
"bin": {
"ocas": "src/index.ts",
},
"dependencies": {
"@ocas/core": "0.1.1",
"@ocas/fs": "0.1.1",
},
},
"packages/core": {
"name": "@ocas/core",
"version": "0.2.0",
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
"liquidjs": "^10.27.0",
"xxhash-wasm": "^1.1.0",
},
},
"packages/fs": {
"name": "@ocas/fs",
"version": "0.2.0",
"dependencies": {
"@ocas/core": "0.1.1",
"cborg": "^4.2.3",
},
},
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@ocas/cli": ["@ocas/cli@workspace:packages/cli"],
"@ocas/core": ["@ocas/core@workspace:packages/core"],
"@ocas/fs": ["@ocas/fs@workspace:packages/fs"],
"@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@shazhou/proman": ["@shazhou/proman@0.1.1", "", { "dependencies": { "@biomejs/biome": "^2.4.16", "typescript": "^5.5.0", "yaml": "^2.9.0" }, "bin": { "proman": "dist/cli.js" } }, "sha512-Ksn1L7rwNj9NnnIlszgmG34z6DrYpBBZcv5TCTnMwWWl29B+dAnpxhMdKPy1SbjEy9hUY5woNuq1kOARWVRr+A=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="],
"@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
"@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="],
"@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="],
"@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
"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=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"cborg": ["cborg@4.5.8", "", { "bin": { "cborg": "lib/bin.js" } }, "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"liquidjs": ["liquidjs@10.27.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
"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=="],
"vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],
"vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
}
}
+68
View File
@@ -0,0 +1,68 @@
# Sync README
When updating README.md files in this monorepo, follow these conventions.
## Scope
- Root `README.md` — project overview and navigation hub
- Per-package `packages/*/README.md` — each package self-contained
## Root README Structure
The root README should have these sections in order:
1. **Title and one-liner** — content-addressed storage for JSON with schema validation
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
3. **Architecture** — dependency layer diagram (text-based)
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib)
5. **Quick Start** — install, build, basic usage
6. **CLI Reference** — brief command list, detailed usage in cli 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
+4 -8
View File
@@ -1,14 +1,13 @@
{
"name": "@ocas/workspace",
"private": true,
"workspaces": [
"packages/*"
],
"devDependencies": {
"@biomejs/biome": "^2.4.16",
"@shazhou/proman": "0.4.2",
"@types/node": "^25.9.1",
"@shazhou/proman": "0.2.0",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"ulidx": "^2.4.1",
"vite": "^8.0.16",
"vitest": "^4.1.8"
},
"scripts": {
@@ -22,9 +21,6 @@
"url": "https://github.com/shazhou-ww/ocas.git"
},
"homepage": "https://github.com/shazhou-ww/ocas",
"engines": {
"node": ">=22.5.0"
},
"bugs": {
"url": "https://github.com/shazhou-ww/ocas/issues"
}
+36 -38
View File
@@ -1,53 +1,51 @@
# @ocas/cli
## 0.4.0 — 2026-06-07
- New `ocas export <root> [<root>...] -o <bundle.tar>` — export CAS closures as self-contained tar bundles.
- New `ocas import <bundle.tar> [--scope @new]` — import bundles into a store.
- New global `--store <bundle.tar>` flag — open a bundle as read-only store for inspection commands.
- Rename `ocas prompt setup` to `ocas prompt bootstrap` with programmatic generation.
- New `ocas prompt list` subcommand.
## 0.3.1 — 2026-06-04
- Fix prompt docs: `bun``pnpm` install instructions, remove stale `--var-db` flag.
## 0.3.0 — 2026-06-03
- No CLI-specific changes. Coordinated version bump with `@ocas/fs` 0.3.0.
## 0.2.2 — 2026-06-03
- Lint and format fixes.
## 0.2.1 — 2026-06-03
- Full CLI build support with `tsc` emit + Node compatibility.
- Migrate runtime from Bun to Node.js + pnpm.
## 0.2.0 — 2026-06-02
## 0.2.0
### Breaking Changes
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead.
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties
- `bootstrap(store)` and `putSchema(store, schema)` are now **synchronous** (were async)
- `VariableStore` class removed from `@ocas/core` — SQLite implementation moved to `@ocas/fs`
- `createVariableStore()` removed from `@ocas/core`
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally
- `@ocas/core` has zero `bun:sqlite` imports — pure TypeScript
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead
### New Features
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands.
- `ocas get` and `ocas var get` now include tag info in output.
- `ocas list --tag` and `ocas var list --tag` filter support.
- `CasStore`, `VarStore`, `TagStore` sub-store types
- `TagStore` — first-class tags on any CAS node (not just variables)
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands
- `ocas get` and `ocas var get` now include tag info in output
- `ocas list --tag` and `ocas var list --tag` filter support
- `var-store-helpers.ts` — shared validation/history logic
- `validation.ts` — shared `validateName()` exported from core
## 0.1.2 — 2026-06-02
## 0.1.2
### Patch Changes
- Fix render output missing trailing newline.
- Add agent skill setup hint with version to help output.
- Remove postinstall script.
- Fix TypeScript LSP errors: tsconfig `noEmit`, `exactOptionalPropertyTypes` conditional spread, `schemaHash` scope.
- Add agent skill setup hint with version to help output. Remove postinstall script (blocked by bun security policy). Update `ocas prompt setup` to guide cleanup of old skill versions before installing new ones.
## 0.1.1 — 2026-06-02
- Updated dependencies:
- @ocas/core@0.1.2
- @ocas/fs@0.1.2
- Add `ocas prompt usage` and `ocas prompt setup` commands.
- Add `--version` flag.
## 0.1.1
## 0.1.0 — 2026-06-01
### Patch Changes
Initial release. CLI tool for OCAS with put, get, list, render, verify, gc, var, and template commands.
- Add `ocas prompt usage` and `ocas prompt setup` commands for agent skill management. Prompt content is bundled with the CLI and versioned with it.
- Add `--version` flag to display CLI version.
- Updated dependencies:
- @ocas/core@0.1.1
- @ocas/fs@0.1.1
## 0.1.0
Initial release as `@ocas/cli`. CLI tool for OCAS with put, get, list, render, verify, gc, var, and template commands. Envelope output format with pipe composition support.
+4 -36
View File
@@ -15,17 +15,17 @@ The store is **auto-created and bootstrapped** on first use, so there is no `ini
Published as an npm package with a binary entry:
```bash
pnpm add -g @ocas/cli
bun add -g @ocas/cli
# or from the monorepo workspace:
pnpm link
bun link
```
**Binary name:** `ocas` (points to `dist/index.js`, run with Node).
**Binary name:** `ocas` (points to `src/index.ts`, run with Bun).
In development:
```bash
node packages/cli/dist/index.js <command> [args]
bun packages/cli-ocas/src/index.ts <command> [args]
```
## CLI Usage
@@ -40,7 +40,6 @@ Usage: ocas [--home <path>] [--json] <command> [args]
|------|-------------|
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
| `--var-db <path>` | Variable database path (default: `<home>/variables.db`) |
| `--store <bundle.tar>` | Open a bundle file as a read-only store (write commands rejected) |
| `--json` | Compact (single-line) JSON output |
### Envelope format
@@ -83,8 +82,6 @@ raw, non-envelope text.
| `template list` | `{ schemaHash, contentHash }[]` | `@output/template-list` |
| `template delete <schema-hash>` | `{ deleted: boolean }` | `@output/template-delete` |
| `gc` | `{ total, reachable, collected, scanned }` | `@output/gc` |
| `export <root...> -o <bundle.tar>` | `{ nodes, vars, tags }` | `@output/export` |
| `import <bundle.tar> [--scope @new]` | nested `{ nodes, vars, tags }` stats | `@output/import` |
### Examples
@@ -146,35 +143,6 @@ ocas render <content-hash>
# → Item: Widget
```
### Bundles (export / import)
`ocas export` walks the transitive CAS closure (refs **and** schema chains) of one or more
roots and writes a self-contained POSIX-tar archive containing every reachable CAS node
(`cas/<hash>.bin`, CBOR-encoded), every variable whose value is in-closure
(`vars.jsonl`), and every tag attached to an in-closure target (`tags.jsonl`).
```bash
ocas export @myapp/config -o myapp.tar # single root by name
ocas export @myapp/config @myapp/users -o m.tar # multiple roots
ocas export 1ABC2DEF34567 -o snapshot.tar # raw hash root
# Import into the current store (idempotent — content-addressed dedup)
ocas import myapp.tar
ocas import myapp.tar --scope @prod # remap @myapp/* → @prod/*
# Inspect a bundle without unpacking it
ocas get @myapp/config --store myapp.tar
ocas walk @myapp/config --store myapp.tar
ocas var list --store myapp.tar
```
`-o <path>` (required for `export`) names the output tar. `--scope @new` rewrites the
leading `@scope` of every imported variable name except builtins (`@ocas/*`).
`--store <bundle.tar>` swaps the store backend for any read-only command (`get`, `has`,
`refs`, `walk`, `list`, `var list`, `var get`, `verify`, `render`, …); write commands
(`put`, `tag`, `gc`, `import`, `var set`, `template set`, …) refuse with
`--store is read-only` when the flag is set.
## Internal Structure
| File | Purpose |
+1 -17
View File
@@ -1,23 +1,10 @@
{
"name": "@ocas/cli",
"version": "0.4.0",
"description": "CLI for OCAS content-addressed store",
"keywords": [
"cas",
"cli",
"content-addressing"
],
"engines": {
"node": ">=22.5.0"
},
"version": "0.2.0",
"type": "module",
"bin": {
"ocas": "dist/index.js"
},
"files": [
"dist",
"prompts"
],
"dependencies": {
"@ocas/core": "workspace:*",
"@ocas/fs": "workspace:*"
@@ -30,8 +17,5 @@
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/cli",
"bugs": {
"url": "https://github.com/shazhou-ww/ocas/issues"
},
"devDependencies": {
"@types/node": "^25.9.1"
}
}
+53
View File
@@ -0,0 +1,53 @@
# OCAS Skill Setup
You are being asked to install or update the OCAS (Object Content Addressable Store) skill
so that you know how to use the `ocas` CLI.
## Steps
1. **Check if OCAS CLI is installed:**
```bash
ocas --help
```
If not installed: `bun add -g @ocas/cli`
2. **Clean up old versions of the skill** (if any exist):
- Look for any existing OCAS/ocas skill files in your skill directories
(e.g. `~/.hermes/skills/ocas/SKILL.md` or similar paths).
- If a skill exists, read its `version` from the YAML frontmatter.
- If the version matches the current CLI version (`ocas --version`), you're done — no update needed.
- If the version is older, **delete the old skill file** before proceeding.
3. **Get the usage skill content:**
```bash
ocas prompt usage
```
4. **Create a SKILL.md file** in your skill directory (e.g. `~/.hermes/skills/ocas/SKILL.md`).
The output of `ocas prompt usage` is the skill body WITHOUT frontmatter.
You must prepend the following YAML frontmatter:
```yaml
---
name: ocas
description: "Use when storing, retrieving, or managing typed JSON data with OCAS (Object Content Addressable Store). Covers CLI usage, variable management, templates, rendering, and pipe composition."
version: <VERSION>
author: OCAS
license: MIT
metadata:
hermes:
tags: [cas, storage, cli, json, schema, content-addressing]
---
```
Replace `<VERSION>` with the installed CLI version (from `ocas --version`).
5. **Verify** the skill is loadable by your agent framework.
## Notes
- The skill content is bundled with the CLI and versioned with it — always use
`ocas prompt usage` to get the content matching your installed version.
- Do NOT hand-edit the skill body. If the CLI is updated, re-run `ocas prompt setup`
and follow the steps again.
- When upgrading, always delete the old skill first to avoid stale instructions.
+2 -23
View File
@@ -6,7 +6,7 @@ OCAS is a self-describing content-addressable store for typed JSON data. Every n
All commands output `{ type, value }` JSON envelopes, making them composable via pipes.
**Install:** `pnpm add -g @ocas/cli`
**Install:** `bun add -g @ocas/cli`
**Packages:** `@ocas/core` (engine) · `@ocas/fs` (filesystem store) · `@ocas/cli` (CLI)
@@ -133,31 +133,12 @@ ocas gc # collect unreachable nodes
ocas gc | ocas render -p # human-readable stats
```
### Bundles (Export / Import)
```bash
ocas export <root>... -o <bundle.tar> # write closure of roots to tar
ocas export @myapp/config -o myapp.tar
ocas export @myapp/config @myapp/users -o m.tar # multiple roots
ocas import <bundle.tar> # import bundle (idempotent)
ocas import <bundle.tar> --scope @prod # remap @<scope>/* → @prod/*
ocas get <hash> --store <bundle.tar> # read-only access into bundle
```
`export` walks refs **and** schema chains; the resulting tar contains every reachable
CAS node (`cas/<hash>.bin`, CBOR), every variable whose value is in-closure, and every
tag attached to an in-closure target. `import` is content-addressed (deduplicates
existing nodes). `--scope @new` rewrites the leading `@scope` of imported variable
names except `@ocas/*` builtins. `--store <bundle.tar>` opens a bundle as a read-only
store for any inspection command; write commands (`put`, `tag`, `gc`, `import`,
`var set`, …) refuse with `--store is read-only`.
### Global Flags
| Flag | Description |
|------|-------------|
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
| `--store <bundle.tar>` | Open a bundle as a read-only store (write commands rejected) |
| `--var-db <path>` | Variable database path |
| `--json` | Compact JSON output |
| `-p`, `--pipe` | Read from stdin |
| `-r`, `--render` | Render output inline |
@@ -165,8 +146,6 @@ store for any inspection command; write commands (`put`, `tag`, `gc`, `import`,
| `--limit <n>` | Max results (default: 100) |
| `--offset <n>` | Skip first N (default: 0) |
| `--desc` | Sort descending |
| `-o <path>` | Output path (used by `export`) |
| `--scope @new` | Variable scope remap (used by `import`) |
## Pipe Composition Patterns
+14 -132
View File
@@ -1,25 +1,19 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { cmdPromptBootstrap } from "./prompt-bootstrap.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
import type { Hash, ListEntry, ListOptions, Store, TagOp } from "@ocas/core";
import {
applyListOptions,
CasNodeNotFoundError,
computeHash,
exportBundle,
gc,
getSchema,
InvalidVariableNameError,
importBundle,
loadBundleStore,
putSchema,
refs,
renderAsync,
@@ -51,9 +45,6 @@ const VALUE_FLAGS = new Set([
"sort",
"limit",
"offset",
"store",
"scope",
"o",
]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
@@ -91,14 +82,6 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
flags.p = true;
} else if (arg === "-r") {
flags.r = true;
} else if (arg === "-o") {
const next = argv[i + 1];
if (next !== undefined && !next.startsWith("--")) {
flags.o = next;
i++;
} else {
flags.o = true;
}
} else {
positional.push(arg);
}
@@ -175,48 +158,15 @@ async function readStdinJson(): Promise<unknown> {
}
}
/**
* Set of write-mutating commands that cannot run against a bundle (read-only).
* Subcommands are also recorded as `cmd:sub`.
*/
const WRITE_COMMANDS = new Set([
"put",
"tag",
"untag",
"gc",
"import",
"var:set",
"var:delete",
"template:set",
"template:delete",
]);
/**
* Open the filesystem-backed Store. Automatically creates directory and
* bootstraps if needed. If `--store <bundle>` is passed, returns a read-only
* bundle-backed Store instead.
* bootstraps if needed.
*/
async function openStore(): Promise<Store> {
if (typeof flags.store === "string") {
return await loadBundleStore(flags.store);
}
const fullPath = resolve(storePath);
return await openFsStore(fullPath);
}
/**
* Reject write commands when --store points at a bundle. Should be called
* from the dispatch layer before any write command runs.
*/
function ensureWritable(commandKey: string): void {
if (typeof flags.store !== "string") return;
if (WRITE_COMMANDS.has(commandKey)) {
die(
`Error: --store is read-only — '${commandKey}' is not allowed against a bundle. Use --home for a writable store.`,
);
}
}
/**
* Hash format check: 13-char uppercase Crockford Base32.
*/
@@ -1038,51 +988,6 @@ async function cmdGc(_args: string[]): Promise<void> {
await out(await wrapEnvelope(store, "@ocas/output/gc", stats), store);
}
async function cmdExport(args: string[]): Promise<void> {
if (args.length === 0) {
die(
"Usage: ocas export <root>... -o <bundle.tar>\n ocas export <hash>... -o <bundle.tar>",
);
}
const output = flags.o;
if (typeof output !== "string") {
die(
"Error: -o <output-path> is required.\nUsage: ocas export <root>... -o <bundle.tar>",
);
}
const store = await openStore();
try {
const stats = await exportBundle(store, args, output);
await out(await wrapEnvelope(store, "@ocas/output/export", stats), store);
} catch (e) {
if (e instanceof Error) {
die(`Error: ${e.message}`);
}
throw e;
}
}
async function cmdImport(args: string[]): Promise<void> {
const bundlePath = args[0];
if (!bundlePath) {
die("Usage: ocas import <bundle.tar> [--scope @newscope]");
}
const scope = typeof flags.scope === "string" ? flags.scope : undefined;
const store = await openStore();
try {
const opts = scope !== undefined ? { scope } : undefined;
const stats = await importBundle(bundlePath, store, opts);
await out(await wrapEnvelope(store, "@ocas/output/import", stats), store);
} catch (e) {
if (e instanceof Error) {
die(`Error: ${e.message}`);
}
throw e;
}
}
async function cmdList(_args: string[]): Promise<void> {
const typeFlag = flags.type;
if (typeof typeFlag !== "string")
@@ -1131,11 +1036,11 @@ async function cmdList(_args: string[]): Promise<void> {
// Get all entries of the requested type (no limit/offset yet) and filter.
const allOfType = store.cas.listByType(typeHash, {
...(opts.sort !== undefined ? { sort: opts.sort } : {}),
...(opts.desc !== undefined ? { desc: opts.desc } : {}),
sort: opts.sort,
desc: opts.desc,
});
const filtered: ListEntry[] = allOfType.filter((e) =>
intersection?.has(e.hash),
intersection!.has(e.hash),
);
const paged = applyListOptions(filtered, opts);
await out(await wrapEnvelope(store, "@ocas/output/list", paged), store);
@@ -1196,12 +1101,9 @@ Commands:
template list List all templates (@ocas/output/template-list)
template delete <schema-hash> Delete template for schema (@ocas/output/template-delete)
gc Run garbage collection (@ocas/output/gc)
export <root>... -o <file> Export CAS closure of roots to a tar bundle
import <bundle> [--scope @s] Import nodes/vars/tags from a bundle into the store
Flags:
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
--store <bundle> Use a tar bundle as a read-only store (instead of --home)
--json Compact JSON output
--render, -r Render output inline (equivalent to | ocas render -p)
--schema <hash> Schema hash filter for var get/delete/tag/list
@@ -1211,10 +1113,8 @@ Flags:
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)
--scope <name> Variable name remap target for import (e.g. --scope @imported)
-o <file> Output path for export
Agent: If you have not installed the ocas skill for this version (${pkg.version}), run \`ocas prompt bootstrap\` and follow the instructions.`);
Agent: If you have not installed the ocas skill for this version (${pkg.version}), run \`ocas prompt setup\` and follow the instructions.`);
}
// ---- Dispatch ----
@@ -1226,14 +1126,6 @@ if (!cmd) {
process.exit(0);
}
// Build the command key (cmd or cmd:sub) used by the read-only guard.
const subCmd = rest[0];
const writeKey =
cmd === "var" || cmd === "template"
? `${cmd}:${subCmd ?? ""}`
: (cmd as string);
ensureWritable(writeKey);
switch (cmd) {
case "put":
await cmdPut(rest);
@@ -1336,38 +1228,28 @@ switch (cmd) {
await cmdGc(rest);
break;
case "export":
await cmdExport(rest);
break;
case "import":
await cmdImport(rest);
break;
case "prompt": {
const [sub] = rest;
switch (sub) {
case "list": {
// biome-ignore lint/suspicious/noConsole: CLI user-facing output
console.log("usage\nbootstrap");
break;
}
case "usage": {
const content = readFileSync(
join(__dirname, "..", "prompts", "usage.md"),
join(__dirname, "prompts", "usage.md"),
"utf-8",
);
process.stdout.write(content);
break;
}
case "bootstrap": {
// biome-ignore lint/suspicious/noConsole: CLI user-facing output
console.log(cmdPromptBootstrap());
case "setup": {
const content = readFileSync(
join(__dirname, "prompts", "setup.md"),
"utf-8",
);
process.stdout.write(content);
break;
}
default:
die(
`Unknown prompt subcommand: ${sub ?? "(none)"}. Available: list, usage, bootstrap`,
`Unknown prompt subcommand: ${sub ?? "(none)"}. Available: usage, setup`,
);
}
break;
-183
View File
@@ -1,183 +0,0 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
// Walk up from __dirname to find the nearest @ocas/cli package.json
function _findCliVersion(): string {
let dir = dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 5; i++) {
const candidate = join(dir, "package.json");
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as {
name?: string;
version?: string;
};
if (pkg.name === "@ocas/cli") {
return pkg.version ?? "0.0.0";
}
} catch {
// not found, keep walking
}
dir = dirname(dir);
}
return "0.0.0";
}
const CLI_VERSION = _findCliVersion();
export function cmdPromptBootstrap(): string {
return `# ocas Bootstrap
Set up or upgrade ocas (Object Content Addressable Store) — from zero to running your first command.
## Scenario A: Fresh Install
### Step 0 — Environment pre-flight check
Run ALL checks below BEFORE installing anything. If any check fails, fix it first — do NOT skip ahead.
\`\`\`bash
# 1. Node.js
node --version # need v20+
# FIX: install via nvm (https://github.com/nvm-sh/nvm) or your OS package manager
# 2. Package manager — pnpm (preferred) or npm
pnpm --version || npm --version
# FIX for pnpm: corepack enable pnpm (Node 20+)
# or: npm install -g pnpm
# If pnpm is not an option, npm works too — adjust install commands below
# 3. Global bin directory is in PATH
# For pnpm:
pnpm bin -g # prints the global bin directory
# For npm:
npm prefix -g # global prefix; bin is <prefix>/bin
# The directory printed above MUST be in your PATH.
# Test: echo \\$PATH | tr ':' '\\n'
# FIX: add the missing directory to your shell's startup file
# (e.g. ~/.bashrc, ~/.zshrc, ~/.profile, or fish config):
# export PATH="<global-bin-dir>:$PATH"
\`\`\`
**All checks must pass before continuing.** If you had to modify PATH, verify the change persists by opening a new shell or sourcing your shell config.
### Step 1 — Install @ocas/cli
\`\`\`bash
# Using pnpm (preferred)
pnpm add -g @ocas/cli
# Using npm
npm install -g @ocas/cli
\`\`\`
**After installing, verify that \`ocas\` is available in PATH:**
\`\`\`bash
ocas --version # should print ${CLI_VERSION}
\`\`\`
If the command is not found, the global bin directory is not in the current shell's PATH. **You must fix this before continuing:**
1. Find where the binary was installed:
\`\`\`bash
find ~/.local /usr/local -name ocas -type f 2>/dev/null
npm prefix -g # global prefix — bin is <prefix>/bin
\`\`\`
2. Add the directory to PATH permanently by appending to the user's shell config (e.g. \`~/.bashrc\`, \`~/.zshrc\`, \`~/.profile\`, or fish config):
\`\`\`bash
export PATH="<global-bin-dir>:$PATH"
\`\`\`
3. Source the updated config or open a new shell, then re-verify the command works.
### Step 2 — Verify version
\`\`\`bash
ocas --version # should print ${CLI_VERSION}
\`\`\`
### Step 3 — Install the ocas skill
Run the usage prompt and save it as a skill so your agent knows how to use ocas:
\`\`\`bash
ocas prompt usage
\`\`\`
The output is the skill body **without** frontmatter. Save it as a skill in your agent framework with the following YAML frontmatter prepended:
\`\`\`yaml
---
name: ocas
description: "Use when storing, retrieving, or managing typed JSON data with OCAS (Object Content Addressable Store). Covers CLI usage, variable management, templates, rendering, and pipe composition."
version: ${CLI_VERSION}
author: OCAS
license: MIT
metadata:
hermes:
tags: [cas, storage, cli, json, schema, content-addressing]
---
\`\`\`
Replace the \`version\` field with the installed CLI version (from \`ocas --version\`).
**⚠ After saving the skill, start a new session** so the agent loads the updated skill content. Skills saved in the current session are not active until the next session.
### Step 4 — End-to-end verify
\`\`\`bash
# Store a string value
ocas put @ocas/string '"hello world"'
# Get it back using the returned hash
ocas get <hash>
\`\`\`
If \`ocas get\` returns \`"hello world"\`, the setup is working.
## Scenario B: Upgrade from Previous Version
### Step 1 — Update to latest
\`\`\`bash
# Using pnpm
pnpm add -g @ocas/cli@latest
# Using npm
npm install -g @ocas/cli@latest
\`\`\`
### Step 2 — Verify version
\`\`\`bash
ocas --version # should print ${CLI_VERSION}
\`\`\`
### Step 3 — Regenerate skill
Skill content is bundled with the CLI — always regenerate after upgrading:
\`\`\`bash
ocas prompt usage # → update skill "ocas"
\`\`\`
Update the \`version\` field in the skill frontmatter to match the new CLI version.
**⚠ After updating the skill, start a new session** to load the new skill content.
### Step 4 — Verify
\`\`\`bash
ocas put @ocas/string '"upgrade test"'
ocas get <hash>
\`\`\`
## Available prompts
\`\`\`bash
ocas prompt list # list available prompt names
ocas prompt usage # CLI usage guide (skill body)
ocas prompt bootstrap # this guide
\`\`\`
`;
}
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Phase 3: Variable System > 3.1 var set creates variable 1`] = `
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
{
"type": "0Q5EMYK4SYSS9",
"value": {
@@ -13,7 +13,7 @@ exports[`Phase 3: Variable System > 3.1 var set creates variable 1`] = `
}
`;
exports[`Phase 3: Variable System > 3.2 var get returns variable 1`] = `
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "F5RRJTXP8Z99D",
"value": {
@@ -26,7 +26,7 @@ exports[`Phase 3: Variable System > 3.2 var get returns variable 1`] = `
}
`;
exports[`Phase 3: Variable System > 3.3 var list shows all variables 1`] = `
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
{
"type": "AF0XACGXHPMC1",
"value": [
@@ -86,13 +86,6 @@ exports[`Phase 3: Variable System > 3.3 var list shows all variables 1`] = `
"tags": {},
"value": "944RT37WX1PQ5",
},
{
"labels": [],
"name": "@ocas/output/export",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "3P2SFAVZXZ474",
},
{
"labels": [],
"name": "@ocas/output/gc",
@@ -121,13 +114,6 @@ exports[`Phase 3: Variable System > 3.3 var list shows all variables 1`] = `
"tags": {},
"value": "1B24CBF95Q5G6",
},
{
"labels": [],
"name": "@ocas/output/import",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "198WWJWDA6KDX",
},
{
"labels": [],
"name": "@ocas/output/list",
@@ -272,7 +258,7 @@ exports[`Phase 3: Variable System > 3.3 var list shows all variables 1`] = `
}
`;
exports[`Phase 3: Variable System > 3.4 var list prefix filters by prefix 1`] = `
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
{
"type": "AF0XACGXHPMC1",
"value": [
@@ -287,7 +273,7 @@ exports[`Phase 3: Variable System > 3.4 var list prefix filters by prefix 1`] =
}
`;
exports[`Phase 3: Variable System > 3.5 var set upsert updates existing variable 1`] = `
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
{
"type": "0Q5EMYK4SYSS9",
"value": {
@@ -300,7 +286,7 @@ exports[`Phase 3: Variable System > 3.5 var set upsert updates existing variable
}
`;
exports[`Phase 3: Variable System > 3.6 var set with tag and label adds them 1`] = `
exports[`Phase 3: Variable System 3.6 var set with tag and label adds them 1`] = `
{
"type": "0Q5EMYK4SYSS9",
"value": {
@@ -317,7 +303,7 @@ exports[`Phase 3: Variable System > 3.6 var set with tag and label adds them 1`]
}
`;
exports[`Phase 3: Variable System > 3.7 var list --tag env:prod filters by kv tag 1`] = `
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "AF0XACGXHPMC1",
"value": [
@@ -336,7 +322,7 @@ exports[`Phase 3: Variable System > 3.7 var list --tag env:prod filters by kv ta
}
`;
exports[`Phase 3: Variable System > 3.8 var list --tag important filters by label 1`] = `
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
{
"type": "AF0XACGXHPMC1",
"value": [
@@ -355,7 +341,7 @@ exports[`Phase 3: Variable System > 3.8 var list --tag important filters by labe
}
`;
exports[`Phase 3: Variable System > 3.9 var set without label removes it 1`] = `
exports[`Phase 3: Variable System 3.9 var set without label removes it 1`] = `
{
"type": "0Q5EMYK4SYSS9",
"value": {
@@ -370,7 +356,7 @@ exports[`Phase 3: Variable System > 3.9 var set without label removes it 1`] = `
}
`;
exports[`Phase 3: Variable System > 3.10 var delete removes variable 1`] = `
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
{
"type": "C3MYPR5RGQFZT",
"value": [
@@ -387,9 +373,11 @@ exports[`Phase 3: Variable System > 3.10 var delete removes variable 1`] = `
}
`;
exports[`Phase 3: Variable System > 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=@myapp/config, schema=FRBAB1BF0ZBCS"`;
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=@myapp/config, schema=FRBAB1BF0ZBCS"`;
exports[`Phase 4: Template System > 4.1 template set registers template 1`] = `
exports[`Phase 4: Template System > 4.5 template get deleted template returns not found 1`] = `"Unknown command: template,get,FRBAB1BF0ZBCS"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
{
"type": "BJDHPAE4Q8TXM",
"value": {
@@ -399,14 +387,14 @@ exports[`Phase 4: Template System > 4.1 template set registers template 1`] = `
}
`;
exports[`Phase 4: Template System > 4.2 template get returns template text 1`] = `
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
{
"type": "0B0HBHZGYHR84",
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
}
`;
exports[`Phase 4: Template System > 4.3 template list shows registered templates 1`] = `
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
{
"type": "8917JQTD1R5JF",
"value": [
@@ -418,7 +406,7 @@ exports[`Phase 4: Template System > 4.3 template list shows registered templates
}
`;
exports[`Phase 4: Template System > 4.4 template delete removes template 1`] = `
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
{
"type": "BY7BGZJND3N7R",
"value": {
@@ -427,15 +415,15 @@ exports[`Phase 4: Template System > 4.4 template delete removes template 1`] = `
}
`;
exports[`Phase 4: Template System > 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: FRBAB1BF0ZBCS"`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: FRBAB1BF0ZBCS"`;
exports[`Phase 7: Edge Cases > 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
exports[`Phase 7: Edge Cases > 7.3 var set empty name errors 1`] = `"Usage: ocas var set <name> <hash> [--tag <tag>...]"`;
exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: ocas var set <name> <hash> [--tag <tag>...]"`;
exports[`Phase 7: Edge Cases > 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Name must follow @scope/name format (e.g. @myapp/config)"`;
exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Name must follow @scope/name format (e.g. @myapp/config)"`;
exports[`Phase 7: Edge Cases > 7.5 no subcommand shows help text 1`] = `
exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = `
"Usage: ocas [--home <path>] [--json] <command> [args]
All JSON commands emit a { type, value } envelope. The type is the hash of the
@@ -467,12 +455,9 @@ Commands:
template list List all templates (@ocas/output/template-list)
template delete <schema-hash> Delete template for schema (@ocas/output/template-delete)
gc Run garbage collection (@ocas/output/gc)
export <root>... -o <file> Export CAS closure of roots to a tar bundle
import <bundle> [--scope @s] Import nodes/vars/tags from a bundle into the store
Flags:
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
--store <bundle> Use a tar bundle as a read-only store (instead of --home)
--json Compact JSON output
--render, -r Render output inline (equivalent to | ocas render -p)
--schema <hash> Schema hash filter for var get/delete/tag/list
@@ -482,8 +467,6 @@ Flags:
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)
--scope <name> Variable name remap target for import (e.g. --scope @imported)
-o <file> Output path for export
Agent: If you have not installed the ocas skill for this version (0.4.0), run \`ocas prompt bootstrap\` and follow the instructions."
Agent: If you have not installed the ocas skill for this version (0.2.0), run \`ocas prompt setup\` and follow the instructions."
`;
@@ -12,3 +12,16 @@ exports[`Phase 1: CAS Core > 1.6 get returns node JSON (snapshot) 1`] = `
},
}
`;
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"type": "7V5G8E2VW8B2G",
"value": {
"payload": {
"age": 30,
"name": "Alice",
},
"type": "FRBAB1BF0ZBCS",
},
}
`;
@@ -1,5 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Phase 5: Render > 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render > 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
@@ -1,3 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Phase 2: Schema Validation > 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
@@ -22,3 +22,26 @@ exports[`Phase 1: CAS Core > 1.11 walk shows traversal tree (snapshot) 1`] = `
]
}"
`;
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
{
"type": "52HEFB52BD0GF",
"value": "ok",
}
`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
"{
"type": "2TKP4RGBJ4V43",
"value": []
}"
`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
"{
"type": "4HG6MD3XG5H5C",
"value": [
"9W3MGR3184QYE"
]
}"
`;
+9 -22
View File
@@ -1,8 +1,8 @@
import { execFileSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
// ---- @ Alias Resolution Tests ----
@@ -17,7 +17,7 @@ beforeEach(() => {
`ocas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dirname, "../dist/index.js");
cliPath = join(import.meta.dirname, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
@@ -35,29 +35,16 @@ afterEach(() => {
/**
* Run CLI command and return stdout, stderr, and exit code
*/
function runCliAlias(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCliAlias(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [cliPath, "--home", storePath, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
+26 -72
View File
@@ -1,11 +1,11 @@
import { execFileSync } from "node:child_process";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
const entrypoint = resolve(import.meta.dirname, "../src/index.ts");
const pkgPath = resolve(import.meta.dirname, "../package.json");
// --- ocas command alias tests (from cli.test.ts) ---
@@ -24,7 +24,7 @@ describe("ocas binary", () => {
});
test("T3: ocas command is executable and shows help", () => {
const stdout = execFileSync("node", [entrypoint, "--help"], {
const stdout = execFileSync("tsx", [entrypoint, "--help"], {
encoding: "utf-8",
timeout: 10000,
});
@@ -39,30 +39,16 @@ describe("Phase 7: Edge Cases", () => {
let typeHash: string;
let nodeHash: string;
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
},
);
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
@@ -141,14 +127,10 @@ describe("Phase 7: Edge Cases", () => {
const fileAsStore = join(tmpStore, "not-a-directory");
writeFileSync(fileAsStore, "test");
try {
execFileSync(
"node",
[entrypoint, "--home", fileAsStore, "get", "AAAAAAAAAAAAA"],
{
encoding: "utf-8",
timeout: 10000,
},
);
execFileSync("tsx", [entrypoint, "--home", fileAsStore, "get", "AAAAAAAAAAAAA"], {
encoding: "utf-8",
timeout: 10000,
});
expect.unreachable("should have thrown");
} catch (e: unknown) {
const err = e as { stderr?: string; status?: number };
@@ -165,30 +147,16 @@ describe("Phase 3: Variable System", () => {
let typeHash: string;
let nodeHash: string;
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
},
);
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
@@ -368,30 +336,16 @@ describe("Phase 4: Template System", () => {
let tmpStore: string;
let typeHash: string;
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
},
);
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
-263
View File
@@ -1,263 +0,0 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { envValue, runCli } from "./helpers";
let storePath: string;
let bundlePath: string;
beforeEach(() => {
storePath = mkdtempSync(join(tmpdir(), "ocas-export-import-"));
bundlePath = join(storePath, "bundle.tar");
});
afterEach(() => {
rmSync(storePath, { recursive: true, force: true });
});
async function setupSampleStore(): Promise<{
schemaHash: string;
nodeHash: string;
}> {
// Create a schema, a node, a variable, and a tag.
const { openStore } = await import("@ocas/fs");
const { putSchema } = await import("@ocas/core");
const store = await openStore(storePath);
const schemaHash = putSchema(store, {
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
});
const nodeHash = store.cas.put(schemaHash, { name: "Alice", age: 30 });
store.var.set("@test/app", nodeHash);
store.tag.tag(nodeHash, [{ op: "set", key: "env", value: "prod" }]);
return { schemaHash, nodeHash };
}
describe("CLI export/import", () => {
test("3.1 export: basic usage with -o flag", async () => {
const { nodeHash } = await setupSampleStore();
const { exitCode, stdout } = runCli(
["export", "@test/app", "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(0);
expect(existsSync(bundlePath)).toBe(true);
const value = envValue(stdout) as {
nodes: number;
vars: number;
tags: number;
};
expect(value.nodes).toBeGreaterThan(0);
expect(value.vars).toBeGreaterThanOrEqual(1);
expect(value.tags).toBeGreaterThanOrEqual(1);
void nodeHash;
});
test("3.2 export: multiple roots", async () => {
const { openStore } = await import("@ocas/fs");
const { putSchema } = await import("@ocas/core");
const store = await openStore(storePath);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "a");
const bHash = store.cas.put(schemaHash, "b");
store.var.set("@test/a", aHash);
store.var.set("@test/b", bHash);
const { exitCode } = runCli(
["export", "@test/a", "@test/b", "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(0);
expect(existsSync(bundlePath)).toBe(true);
});
test("3.3 export: hash as root", async () => {
const { nodeHash } = await setupSampleStore();
const { exitCode } = runCli(
["export", nodeHash, "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(0);
});
test("3.4 export: missing root → error", async () => {
await setupSampleStore();
const { exitCode, stderr } = runCli(
["export", "@test/nonexistent", "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(1);
expect(stderr.length).toBeGreaterThan(0);
});
test("3.5 export: missing -o flag → error", async () => {
await setupSampleStore();
const { exitCode, stderr } = runCli(["export", "@test/app"], storePath);
expect(exitCode).toBe(1);
expect(stderr).toMatch(/-o|output/i);
});
test("3.6 import: basic", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-dst-"));
try {
const { exitCode, stdout } = runCli(["import", bundlePath], dstPath);
expect(exitCode).toBe(0);
const stats = envValue(stdout) as {
nodes: { imported: number; skipped: number };
vars: { created: number; updated: number };
tags: number;
};
expect(stats.nodes.imported).toBeGreaterThan(0);
// Variable accessible in dst.
const get = runCli(["get", "@test/app"], dstPath);
expect(get.exitCode).toBe(0);
} finally {
rmSync(dstPath, { recursive: true, force: true });
}
});
test("3.7 import --scope remaps variables", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-scope-"));
try {
const { exitCode } = runCli(
["import", bundlePath, "--scope", "@imported"],
dstPath,
);
expect(exitCode).toBe(0);
const list = runCli(["var", "list", "@imported"], dstPath);
expect(list.exitCode).toBe(0);
const variables = envValue(list.stdout) as Array<{ name: string }>;
expect(variables.some((v) => v.name === "@imported/app")).toBe(true);
} finally {
rmSync(dstPath, { recursive: true, force: true });
}
});
test("3.8 import is idempotent", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-idem-"));
try {
runCli(["import", bundlePath], dstPath);
const second = runCli(["import", bundlePath], dstPath);
expect(second.exitCode).toBe(0);
const stats = envValue(second.stdout) as {
nodes: { imported: number; skipped: number };
};
expect(stats.nodes.imported).toBe(0);
expect(stats.nodes.skipped).toBeGreaterThan(0);
} finally {
rmSync(dstPath, { recursive: true, force: true });
}
});
test("3.9 --store flag: ocas get reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode, stdout } = runCli([
"get",
nodeHash,
"--store",
bundlePath,
]);
expect(exitCode).toBe(0);
const value = envValue(stdout) as { payload: { name: string } };
expect(value.payload.name).toBe("Alice");
});
test("3.10 --store flag: ocas var list reads from bundle", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode, stdout } = runCli([
"var",
"list",
"@test",
"--store",
bundlePath,
]);
expect(exitCode).toBe(0);
const variables = envValue(stdout) as Array<{ name: string }>;
expect(variables.some((v) => v.name === "@test/app")).toBe(true);
});
test("3.11 --store flag: ocas walk reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode, stdout } = runCli([
"walk",
nodeHash,
"--store",
bundlePath,
]);
expect(exitCode).toBe(0);
const hashes = envValue(stdout) as string[];
expect(hashes).toContain(nodeHash);
});
test("3.12 --store flag: ocas refs reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode } = runCli(["refs", nodeHash, "--store", bundlePath]);
expect(exitCode).toBe(0);
});
test("3.13 --store flag: ocas has reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const present = runCli(["has", nodeHash, "--store", bundlePath]);
expect(present.exitCode).toBe(0);
expect(envValue(present.stdout)).toBe(true);
const missing = runCli(["has", "AAAAAAAAAAAAA", "--store", bundlePath]);
expect(missing.exitCode).toBe(0);
expect(envValue(missing.stdout)).toBe(false);
});
test("3.14 --store flag: write commands fail with 'read-only' error", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
// Try `ocas put` against a bundle.
const tmp = mkdtempSync(join(tmpdir(), "ocas-store-write-"));
try {
const payload = join(tmp, "p.json");
writeFileSync(payload, JSON.stringify({ name: "X" }));
const { exitCode, stderr } = runCli([
"put",
"@ocas/string",
payload,
"--store",
bundlePath,
]);
expect(exitCode).toBe(1);
expect(stderr).toMatch(/read[- ]only/i);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
// Suppress unused.
void mkdirSync;
+14 -128
View File
@@ -1,11 +1,11 @@
import { execFileSync } from "node:child_process";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue } from "./helpers";
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
const entrypoint = resolve(import.meta.dirname, "../src/index.ts");
let tmpStore: string;
let typeHash: string;
@@ -42,29 +42,16 @@ afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
@@ -89,15 +76,11 @@ describe("Phase 6: GC", () => {
const { stdout: gcOut, exitCode: gcExit } = runCli(["gc"]);
expect(gcExit).toBe(0);
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, "render", "--pipe"],
{
input: gcOut,
encoding: "utf-8",
timeout: 10000,
},
).trim();
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, "render", "--pipe"], {
input: gcOut,
encoding: "utf-8",
timeout: 10000,
}).trim();
// gc value is an object { total, reachable, collected, scanned }
expect(stdout).toContain("total:");
});
@@ -123,100 +106,3 @@ describe("Phase 6: GC", () => {
expect(envValue(afterGc)).toBe(false);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #93: gc must not collect uwf-style step chains joined by oneOf prev
// ──────────────────────────────────────────────────────────────────────────────
describe("GC #93 - oneOf step chain CLI integration", () => {
test("6.5 gc preserves step chain joined by oneOf prev", async () => {
const subStore = mkdtempSync(join(tmpdir(), "ocas-e2e-93-"));
try {
const { openStore: openFsStore } = await import("@ocas/fs");
const { putSchema } = await import("@ocas/core");
const store = await openFsStore(subStore);
const stepSchemaHash = putSchema(store, {
type: "object",
properties: {
payload: { type: "string" },
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const step1File = join(subStore, "step1.json");
writeFileSync(step1File, JSON.stringify({ payload: "a", prev: null }));
const runCliSub = (
args: string[],
): { stdout: string; stderr: string; exitCode: number } => {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", subStore, ...args],
{ encoding: "utf-8", timeout: 10000 },
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as {
stdout?: string;
stderr?: string;
status?: number;
};
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
};
const { stdout: s1Out } = runCliSub(["put", stepSchemaHash, step1File]);
const step1Hash = envValue(s1Out) as string;
const step2File = join(subStore, "step2.json");
writeFileSync(
step2File,
JSON.stringify({ payload: "b", prev: step1Hash }),
);
const { stdout: s2Out } = runCliSub(["put", stepSchemaHash, step2File]);
const step2Hash = envValue(s2Out) as string;
const step3File = join(subStore, "step3.json");
writeFileSync(
step3File,
JSON.stringify({ payload: "c", prev: step2Hash }),
);
const { stdout: s3Out } = runCliSub(["put", stepSchemaHash, step3File]);
const step3Hash = envValue(s3Out) as string;
const orphanFile = join(subStore, "orphan-step.json");
writeFileSync(
orphanFile,
JSON.stringify({ payload: "orphan", prev: null }),
);
const { stdout: orphanOut } = runCliSub([
"put",
stepSchemaHash,
orphanFile,
]);
const orphanHash = envValue(orphanOut) as string;
runCliSub(["var", "set", "@test/thread/head", step3Hash]);
const { exitCode } = runCliSub(["gc"]);
expect(exitCode).toBe(0);
const { stdout: has1 } = runCliSub(["has", step1Hash]);
expect(envValue(has1)).toBe(true);
const { stdout: has2 } = runCliSub(["has", step2Hash]);
expect(envValue(has2)).toBe(true);
const { stdout: has3 } = runCliSub(["has", step3Hash]);
expect(envValue(has3)).toBe(true);
const { stdout: hasOrphan } = runCliSub(["has", orphanHash]);
expect(envValue(hasOrphan)).toBe(false);
} finally {
rmSync(subStore, { recursive: true, force: true });
}
});
});
+9 -22
View File
@@ -1,11 +1,11 @@
import { execFileSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash } from "@ocas/core";
import { bootstrap, validate } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
let testDir: string;
let storePath: string;
@@ -17,7 +17,7 @@ beforeEach(() => {
`ocas-get-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dirname, "../dist/index.js");
cliPath = join(import.meta.dirname, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
@@ -30,29 +30,16 @@ afterEach(() => {
}
});
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [cliPath, "--home", storePath, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
+6 -18
View File
@@ -1,4 +1,3 @@
import { execFileSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
@@ -6,6 +5,7 @@ import {
rmSync,
writeFileSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { JSONSchema } from "@ocas/core";
@@ -23,7 +23,7 @@ export {
writeFileSync,
};
export const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
export const entrypoint = resolve(import.meta.dirname, "../src/index.ts");
export const pkgPath = resolve(import.meta.dirname, "../package.json");
/** Extract the `value` field from a { type, value } envelope JSON string. */
@@ -47,8 +47,6 @@ export async function putSchemaFile(
return hash;
}
const quietEnv = { ...process.env, NODE_NO_WARNINGS: "1" };
/**
* Run CLI command. Accepts either a string[] or ...string[] (rest args).
* If first arg is an array, uses that as args. Otherwise treats all args as the command.
@@ -61,19 +59,14 @@ export function runCli(
? [entrypoint, "--home", storePath, ...args]
: [entrypoint, ...args];
try {
const stdout = execFileSync("node", finalArgs, {
const stdout = execFileSync("tsx", finalArgs, {
encoding: "utf-8",
timeout: 10000,
env: quietEnv,
});
return { stdout, stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
exitCode: err.status ?? 1,
};
return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", exitCode: err.status ?? 1 };
}
}
@@ -84,20 +77,15 @@ export function runCliWithStdin(
): { stdout: string; stderr: string; exitCode: number } {
const finalArgs = [entrypoint, "--home", storePath, ...args];
try {
const stdout = execFileSync("node", finalArgs, {
const stdout = execFileSync("tsx", finalArgs, {
input: stdin,
encoding: "utf-8",
timeout: 10000,
env: quietEnv,
});
return { stdout, stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
exitCode: err.status ?? 1,
};
return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", exitCode: err.status ?? 1 };
}
}
+1 -1
View File
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BOOTSTRAP_STORE } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { envValue, runCli } from "./helpers.js";
let storePath: string;
+8 -12
View File
@@ -1,8 +1,8 @@
import { execFileSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { envValue, runCli } from "./helpers.js";
const HASH_RE = /^[0-9A-HJKMNP-TV-Z]{13}$/;
@@ -19,16 +19,12 @@ afterEach(() => {
});
async function putString(text: string): Promise<string> {
const entrypoint = join(import.meta.dirname, "../dist/index.js");
const out = execFileSync(
"node",
[entrypoint, "--home", storePath, "put", "@ocas/string", "--pipe"],
{
input: JSON.stringify(text),
encoding: "utf-8",
timeout: 10000,
},
);
const entrypoint = join(import.meta.dirname, "../src/index.ts");
const out = execFileSync("tsx", [entrypoint, "--home", storePath, "put", "@ocas/string", "--pipe"], {
input: JSON.stringify(text),
encoding: "utf-8",
timeout: 10000,
});
return (JSON.parse(out) as { value: string }).value;
}
+14 -31
View File
@@ -1,8 +1,8 @@
import { execFileSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
let testDir: string;
let storePath: string;
@@ -14,7 +14,7 @@ beforeEach(() => {
`ocas-list-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dirname, "../dist/index.js");
cliPath = join(import.meta.dirname, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
@@ -27,43 +27,26 @@ afterEach(() => {
}
});
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [cliPath, "--home", storePath, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
function putString(value: string): string {
try {
const out = execFileSync(
"node",
[cliPath, "--home", storePath, "put", "@ocas/string", "--pipe"],
{
input: JSON.stringify(value),
encoding: "utf-8",
timeout: 10000,
},
);
const out = execFileSync("tsx", [cliPath, "--home", storePath, "put", "@ocas/string", "--pipe"], {
input: JSON.stringify(value),
encoding: "utf-8",
timeout: 10000,
});
return JSON.parse(out.trim()).value as string;
} catch (e: unknown) {
const err = e as { stderr?: string };
+15 -36
View File
@@ -1,11 +1,11 @@
import { execFileSync } from "node:child_process";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue } from "./helpers";
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
const entrypoint = resolve(import.meta.dirname, "../src/index.ts");
let tmpStore: string;
let typeHash: string;
@@ -44,29 +44,16 @@ afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
@@ -75,23 +62,15 @@ function runCliWithStdin(
stdin: string,
): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
input: stdin,
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, ...args], {
input: stdin,
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
+11 -21
View File
@@ -1,11 +1,11 @@
import { execFileSync } from "node:child_process";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
const entrypoint = resolve(import.meta.dirname, "../src/index.ts");
let tmpStore: string;
let typeHash: string;
@@ -42,28 +42,18 @@ afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
function runCli(args: string[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
function runCli(
args: string[],
): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
+15 -36
View File
@@ -1,13 +1,13 @@
import { execFileSync } from "node:child_process";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { bootstrap } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue, putSchemaFile, runCli, runCliWithStdin } from "./helpers";
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
const entrypoint = resolve(import.meta.dirname, "../src/index.ts");
// --- Standalone render tests from cli.test.ts ---
@@ -58,29 +58,16 @@ describe("Phase 5: Render", () => {
let typeHash: string;
let nodeHash: string;
function runCliE2e(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCliE2e(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
@@ -89,23 +76,15 @@ describe("Phase 5: Render", () => {
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
input: stdin,
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, ...args], {
input: stdin,
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
+17 -34
View File
@@ -1,8 +1,8 @@
import { execFileSync } from "node:child_process";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue, putSchemaFile, runCli } from "./helpers";
// ---- Issue #50: Schema Validation in put Command ----
@@ -560,7 +560,7 @@ describe("Phase 2: Schema Validation", () => {
let typeHash: string;
let _nodeHash: string;
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
const entrypoint = resolve(import.meta.dirname, "../src/index.ts");
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
@@ -582,14 +582,10 @@ describe("Phase 2: Schema Validation", () => {
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, "put", typeHash, nodeFile],
{
encoding: "utf-8",
timeout: 10000,
},
).trim();
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, "put", typeHash, nodeFile], {
encoding: "utf-8",
timeout: 10000,
}).trim();
_nodeHash = envValue(stdout) as string;
});
@@ -600,19 +596,12 @@ describe("Phase 2: Schema Validation", () => {
test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => {
const badFile = join(tmpStore, "bad-node.json");
writeFileSync(badFile, JSON.stringify({ name: 123 }));
let stdout = "",
stderr = "",
exitCode = 0;
let stdout = "", stderr = "", exitCode = 0;
try {
stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, "put", typeHash, badFile],
{
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
},
).trim();
stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, "put", typeHash, badFile], {
encoding: "utf-8",
timeout: 10000,
}).trim();
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
stdout = (err.stdout ?? "").trim();
@@ -627,18 +616,12 @@ describe("Phase 2: Schema Validation", () => {
test("2.3 put against non-existent schema hash fails", async () => {
const nodeFile = join(tmpStore, "test-node.json");
let exitCode = 0,
stderr = "";
let exitCode = 0, stderr = "";
try {
execFileSync(
"node",
[entrypoint, "--home", tmpStore, "put", "AAAAAAAAAAAAA", nodeFile],
{
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
},
);
execFileSync("tsx", [entrypoint, "--home", tmpStore, "put", "AAAAAAAAAAAAA", nodeFile], {
encoding: "utf-8",
timeout: 10000,
});
} catch (e: unknown) {
const err = e as { stderr?: string; status?: number };
stderr = (err.stderr ?? "").trim();
+9 -22
View File
@@ -1,11 +1,11 @@
import { execFileSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash } from "@ocas/core";
import { bootstrap } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
let testDir: string;
let storePath: string;
@@ -17,7 +17,7 @@ beforeEach(() => {
`ocas-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dirname, "../dist/index.js");
cliPath = join(import.meta.dirname, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
@@ -30,29 +30,16 @@ afterEach(() => {
}
});
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [cliPath, "--home", storePath, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
+9 -22
View File
@@ -1,11 +1,11 @@
import { execFileSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
import { bootstrap } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
// ---- Test helpers ----
@@ -20,7 +20,7 @@ beforeEach(() => {
`ocas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dirname, "../dist/index.js");
cliPath = join(import.meta.dirname, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
@@ -38,29 +38,16 @@ afterEach(() => {
/**
* Run CLI command and return stdout, stderr, and exit code
*/
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [cliPath, "--home", storePath, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
const usagePath = join(import.meta.dirname, "..", "prompts", "usage.md");
+9 -22
View File
@@ -1,11 +1,11 @@
import { execFileSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
import { bootstrap } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
let testDir: string;
let storePath: string;
@@ -17,7 +17,7 @@ beforeEach(() => {
`ocas-history-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dirname, "../dist/index.js");
cliPath = join(import.meta.dirname, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
@@ -31,29 +31,16 @@ afterEach(() => {
}
});
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [cliPath, "--home", storePath, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
+13 -30
View File
@@ -1,11 +1,11 @@
import { execFileSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
import { bootstrap, putSchema } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
// ---- Test helpers ----
@@ -20,7 +20,7 @@ beforeEach(() => {
`ocas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dirname, "../dist/index.js");
cliPath = join(import.meta.dirname, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
@@ -38,29 +38,16 @@ afterEach(() => {
/**
* Run CLI command and return stdout, stderr, and exit code
*/
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
function runCli(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [cliPath, "--home", storePath, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
@@ -783,14 +770,10 @@ describe("global options", () => {
const hash = await createTestNode(store, typeHash, { test: "data" });
// Override with custom store path
execFileSync(
"node",
[cliPath, "--home", customStorePath, "var", "set", "@test/x", hash],
{
encoding: "utf-8",
timeout: 10000,
},
);
execFileSync("tsx", [cliPath, "--home", customStorePath, "var", "set", "@test/x", hash], {
encoding: "utf-8",
timeout: 10000,
});
});
});
+11 -21
View File
@@ -1,11 +1,11 @@
import { execFileSync } from "node:child_process";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
const entrypoint = resolve(import.meta.dirname, "../src/index.ts");
let tmpStore: string;
let typeHash: string;
@@ -39,28 +39,18 @@ afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
function runCli(args: string[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
function runCli(
args: string[],
): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
const stdout = execFileSync("tsx", [entrypoint, "--home", tmpStore, ...args], {
encoding: "utf-8",
timeout: 10000,
});
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
return { stdout: (err.stdout ?? "").trim(), stderr: (err.stderr ?? "").trim(), exitCode: err.status ?? 1 };
}
}
+4 -1
View File
@@ -6,5 +6,8 @@
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"],
"references": [{ "path": "../core" }, { "path": "../fs" }]
"references": [
{ "path": "../core" },
{ "path": "../fs" }
]
}
+25 -37
View File
@@ -1,52 +1,40 @@
# @ocas/core
## 0.4.1 — 2026-06-07
- Fix `gc` failing to preserve nodes referenced via `oneOf``collectRefs()` now traverses `oneOf` branches alongside existing `anyOf`/`allOf`/`if-then-else` handling.
## 0.4.0 — 2026-06-07
- New `computeClosure(store, roots)` — traverses references and schema chains to gather a complete CAS closure.
- New `exportBundle()` / `importBundle()` / `loadBundleStore()` — produce and consume self-contained POSIX-tar bundles (`cas/*.bin` CBOR payloads, `vars.jsonl`, `tags.jsonl`).
- New builtin output schemas: `@ocas/output/export`, `@ocas/output/import`.
## 0.3.0 — 2026-06-03
- No API changes. Coordinated version bump with `@ocas/fs` 0.3.0.
## 0.2.2 — 2026-06-03
- Lint and format fixes.
## 0.2.1 — 2026-06-03
- Migrate runtime from Bun to Node.js + pnpm.
- Migrate test framework from `bun:test` to Vitest.
- Extract `VariableStore` SQLite implementation to `@ocas/fs`.
## 0.2.0 — 2026-06-02
## 0.2.0
### Breaking Changes
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties.
- `bootstrap(store)` and `putSchema(store, schema)` are now synchronous.
- `VariableStore` class removed — SQLite implementation moved to `@ocas/fs`.
- `createVariableStore()` removed.
- Zero `bun:sqlite` imports — pure TypeScript.
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties
- `bootstrap(store)` and `putSchema(store, schema)` are now **synchronous** (were async)
- `VariableStore` class removed from `@ocas/core` — SQLite implementation moved to `@ocas/fs`
- `createVariableStore()` removed from `@ocas/core`
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally
- `@ocas/core` has zero `bun:sqlite` imports — pure TypeScript
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead
### New Features
- `CasStore`, `VarStore`, `TagStore` sub-store types.
- `validation.ts` — shared `validateName()` exported from core.
- `CasStore`, `VarStore`, `TagStore` sub-store types
- `TagStore` — first-class tags on any CAS node (not just variables)
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands
- `ocas get` and `ocas var get` now include tag info in output
- `ocas list --tag` and `ocas var list --tag` filter support
- `var-store-helpers.ts` — shared validation/history logic
- `validation.ts` — shared `validateName()` exported from core
## 0.1.2 — 2026-06-02
## 0.1.2
### Patch Changes
- Fix TypeScript LSP errors: tsconfig `noEmit`, `exactOptionalPropertyTypes` conditional spread, `schemaHash` scope.
## 0.1.1 — 2026-06-02
## 0.1.1
- Internal improvements.
### Patch Changes
## 0.1.0 — 2026-06-01
- Internal improvements for v0.1.1 release.
Initial release. Content-addressable store engine with JSON Schema typed nodes, XXH64 hashing, variable store, render with resolution decay, and LiquidJS template support.
## 0.1.0
Initial release as `@ocas/core`. Content-addressable store engine with JSON Schema typed nodes, XXH64 hashing, variable store, render with resolution decay, and LiquidJS template support.
+9 -48
View File
@@ -6,14 +6,14 @@ Core CAS engine — hashing, schema, store, verify, bootstrap.
`@ocas/core` is the foundation of the ocas 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: `@ocas/fs` provides persistence, and `@ocas/cli` exposes store operations on the command line.
Other packages build on this layer: `ocas-fs` provides persistence, and `cli-ocas` exposes store operations on the command line.
**Dependencies:** `ajv`, `cborg`, `liquidjs`, `xxhash-wasm`
**Dependencies:** `ajv`, `cborg`, `xxhash-wasm`
## Installation
```bash
pnpm add @ocas/core
bun add @ocas/core
```
## API
@@ -32,7 +32,12 @@ type CasNode<T = unknown> = {
timestamp: number; // Unix epoch ms
};
type Store = { cas: CasStore; var: VarStore; tag: TagStore; };
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>;
@@ -99,48 +104,6 @@ async function verify(hash: Hash, node: CasNode): Promise<boolean>;
Recomputes hash from `node` and compares to `hash` (self-referencing vs normal rules).
### Closure & Bundles
```typescript
type ClosureResult = {
nodes: Set<Hash>;
vars: Variable[];
tags: Map<Hash, Tag[]>;
};
function computeClosure(store: Store, roots: Hash[]): ClosureResult;
type ExportStats = { nodes: number; vars: number; tags: number };
type ImportOptions = { scope?: string };
type ImportStats = {
nodes: { imported: number; skipped: number };
vars: { created: number; updated: number };
tags: number;
};
async function exportBundle(
store: Store,
roots: Hash[],
outputPath: string,
): Promise<ExportStats>;
async function importBundle(
bundlePath: string,
target: Store,
options?: ImportOptions,
): Promise<ImportStats>;
async function loadBundleStore(bundlePath: string): Promise<Store>;
```
- `computeClosure` — walks `cas_ref` edges and schema chains from each root,
also gathering every `Variable` whose `value` lands in the closure and every
`Tag` attached to an in-closure target.
- `exportBundle` — writes a self-contained POSIX-tar archive containing
`cas/<hash>.bin` (CBOR-encoded payloads), `vars.jsonl`, and `tags.jsonl`.
- `importBundle` — content-addressed merge into `target`. Idempotent:
re-importing the same bundle yields zero `imported` and zero `created`.
`options.scope` rewrites the leading `@scope/` of every imported variable
name except `@ocas/*` builtins.
- `loadBundleStore` — convenience that returns an in-memory `Store` populated
from a bundle (for read-only inspection without touching the persistent store).
### Example
```typescript
@@ -191,8 +154,6 @@ walk(store, bobHash, (h) => console.log(h)); // bobHash, aliceHash
| `mem-store.ts` | Alternate in-memory store (tests only; not exported) |
| `schema.ts` | Schema put/get/validate, `refs`, `walk` |
| `verify.ts` | Node integrity verification |
| `closure.ts` | `computeClosure` — refs + schema chain traversal |
| `bundle.ts` | `exportBundle`, `importBundle`, `loadBundleStore` (POSIX tar) |
| `index.ts` | Public exports |
Tests live in `src/*.test.ts` and `tests/`.
+1 -14
View File
@@ -1,16 +1,6 @@
{
"name": "@ocas/core",
"version": "0.4.1",
"description": "Core CAS engine — hashing, schema, store, verify, bootstrap",
"keywords": [
"cas",
"content-addressing",
"json-schema",
"typescript"
],
"engines": {
"node": ">=22.5.0"
},
"version": "0.2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -38,8 +28,5 @@
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/core",
"bugs": {
"url": "https://github.com/shazhou-ww/ocas/issues"
},
"devDependencies": {
"@types/node": "^25.9.1"
}
}
+3 -5
View File
@@ -27,8 +27,6 @@ const OUTPUT_ALIASES = [
"@ocas/output/template-list",
"@ocas/output/template-delete",
"@ocas/output/gc",
"@ocas/output/export",
"@ocas/output/import",
] as const;
// ──────────────────────────────────────────────────────────────────────────────
@@ -36,11 +34,11 @@ const OUTPUT_ALIASES = [
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of 33 built-in schema aliases to hashes", async () => {
test("should return map of 31 built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = bootstrap(store);
// Should return object with 9 primitive + 24 output aliases = 33
// Should return object with 9 primitive + 22 output aliases = 31
expect(builtinSchemas).toHaveProperty("@ocas/schema");
expect(builtinSchemas).toHaveProperty("@ocas/string");
expect(builtinSchemas).toHaveProperty("@ocas/number");
@@ -55,7 +53,7 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(33);
expect(Object.keys(builtinSchemas)).toHaveLength(31);
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
-36
View File
@@ -367,42 +367,6 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
title: "ocas gc result",
},
],
[
"@ocas/output/export",
{
type: "object",
properties: {
nodes: { type: "number" },
vars: { type: "number" },
tags: { type: "number" },
},
title: "ocas export result",
},
],
[
"@ocas/output/import",
{
type: "object",
properties: {
nodes: {
type: "object",
properties: {
imported: { type: "number" },
skipped: { type: "number" },
},
},
vars: {
type: "object",
properties: {
created: { type: "number" },
updated: { type: "number" },
},
},
tags: { type: "number" },
},
title: "ocas import result",
},
],
];
/**
-423
View File
@@ -1,423 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { exportBundle, importBundle, loadBundleStore } from "./bundle.js";
import { cborEncode } from "./cbor.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "ocas-bundle-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("exportBundle / importBundle / loadBundleStore", () => {
test("2.1 export: tar file structure includes cas/, vars.jsonl, tags.jsonl", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, {
type: "object",
properties: { x: { type: "number" } },
});
const aHash = store.cas.put(schemaHash, { x: 42 });
store.var.set("@test/config", aHash);
store.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
const stats = await exportBundle(store, ["@test/config"], out);
const buf = readFileSync(out);
// Standard tar should have 512-byte aligned blocks.
expect(buf.length % 512).toBe(0);
// Parse out the entry names from the tar.
const names = listTarEntries(buf);
expect(names.some((n) => n === `cas/${aHash}.bin`)).toBe(true);
expect(names.some((n) => n === `cas/${schemaHash}.bin`)).toBe(true);
expect(names).toContain("vars.jsonl");
expect(names).toContain("tags.jsonl");
expect(stats.nodes).toBeGreaterThanOrEqual(2);
expect(stats.vars).toBeGreaterThanOrEqual(1);
expect(stats.tags).toBeGreaterThanOrEqual(1);
});
test("2.2 export: CAS node binary identity is preserved", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "hello");
store.var.set("@test/h", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(store, ["@test/h"], out);
const buf = readFileSync(out);
const entries = readTarEntries(buf);
const casEntry = entries.find((e) => e.name === `cas/${aHash}.bin`);
expect(casEntry).toBeDefined();
const node = store.cas.get(aHash);
expect(node).not.toBeNull();
if (!node) return;
const expected = cborEncode({
type: node.type,
payload: node.payload,
timestamp: node.timestamp,
});
expect(casEntry?.content).toEqual(expected);
});
test("2.3 export: vars.jsonl contains parseable JSON lines", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "a");
const bHash = store.cas.put(schemaHash, "b");
store.var.set("@test/a", aHash);
store.var.set("@test/b", bHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(store, ["@test/a", "@test/b"], out);
const entries = readTarEntries(readFileSync(out));
const vars = entries.find((e) => e.name === "vars.jsonl");
expect(vars).toBeDefined();
const text = new TextDecoder().decode(vars?.content);
const lines = text.split("\n").filter((l) => l.length > 0);
const records = lines.map(
(l) => JSON.parse(l) as { name: string; value: string },
);
const names = records.map((r) => r.name);
expect(names).toContain("@test/a");
expect(names).toContain("@test/b");
const aRec = records.find((r) => r.name === "@test/a");
expect(aRec?.value).toBe(aHash);
});
test("2.4 export: tags.jsonl contains target/key/value records", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "tagged");
store.var.set("@test/t", aHash);
store.tag.tag(aHash, [
{ op: "set", key: "env", value: "prod" },
{ op: "set", key: "stable" },
]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(store, ["@test/t"], out);
const entries = readTarEntries(readFileSync(out));
const tagEntry = entries.find((e) => e.name === "tags.jsonl");
expect(tagEntry).toBeDefined();
const text = new TextDecoder().decode(tagEntry?.content);
const lines = text.split("\n").filter((l) => l.length > 0);
const records = lines.map(
(l) =>
JSON.parse(l) as {
target: string;
key: string;
value: string | null;
},
);
const env = records.find((r) => r.key === "env");
expect(env?.value).toBe("prod");
expect(env?.target).toBe(aHash);
const stable = records.find((r) => r.key === "stable");
expect(stable?.value).toBeNull();
});
test("2.5 export: accepts variable names and raw hashes as roots", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "x");
store.var.set("@test/c", aHash);
const out1 = join(tmpDir, "by-name.tar");
const out2 = join(tmpDir, "by-hash.tar");
await exportBundle(store, ["@test/c"], out1);
await exportBundle(store, [aHash], out2);
const names1 = listTarEntries(readFileSync(out1));
const names2 = listTarEntries(readFileSync(out2));
expect(names1).toContain(`cas/${aHash}.bin`);
expect(names2).toContain(`cas/${aHash}.bin`);
});
test("2.6 export: non-existent root throws", async () => {
const store = createMemoryStore();
bootstrap(store);
const out = join(tmpDir, "bundle.tar");
await expect(
exportBundle(store, ["@test/nonexistent"], out),
).rejects.toThrow();
});
test("2.7 import: nodes are written to target store", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, {
type: "object",
properties: { x: { type: "number" } },
});
const aHash = src.cas.put(schemaHash, { x: 1 });
src.var.set("@test/c", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst);
expect(dst.cas.has(aHash)).toBe(true);
const node = dst.cas.get(aHash);
expect(node?.type).toBe(schemaHash);
expect(node?.payload).toEqual({ x: 1 });
});
test("2.8 import: skip existing nodes (content-addressed dedup)", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "a");
src.var.set("@test/c", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
// Pre-populate destination with the same node
dst.cas.put(schemaHash, "a"); // wait — schemaHash may not exist in dst
// To deduplicate, we need to ensure the same hash is computed.
// Re-import the schema first via import.
const stats = await importBundle(out, dst);
// After two imports the second's nodes.skipped should equal nodes.imported of the first.
const stats2 = await importBundle(out, dst);
expect(stats2.nodes.skipped).toBeGreaterThan(0);
expect(stats2.nodes.imported).toBe(0);
void stats;
});
test("2.9 import: variables created without scope use original names", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst);
const v = dst.var.get("@test/config");
expect(v?.value).toBe(aHash);
});
test("2.10 import: scope remapping rewrites variable names", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst, { scope: "@imported" });
const remapped = dst.var.get("@imported/config");
expect(remapped?.value).toBe(aHash);
const original = dst.var.get("@test/config");
expect(original).toBeNull();
});
test("2.11 import: @ocas/* builtin variables are NOT remapped", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst, { scope: "@imported" });
// @ocas/schema, @ocas/string etc. should still be reachable as-is.
expect(dst.var.get("@ocas/schema")).not.toBeNull();
// No variant under the remapped scope.
expect(dst.var.get("@imported/schema")).toBeNull();
});
test("2.12 import: variable conflict — overwrite with stats marking 'updated'", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "imported");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
// Pre-populate destination with same name → different value.
const dstSchema = putSchema(dst, { type: "string" });
const bHash = dst.cas.put(dstSchema, "preexisting");
dst.var.set("@test/config", bHash);
const stats = await importBundle(out, dst);
expect(stats.vars.updated).toBeGreaterThanOrEqual(1);
// Value should now point at the imported hash.
const v = dst.var.get("@test/config", schemaHash);
expect(v?.value).toBe(aHash);
});
test("2.13 import: tags are applied to imported nodes", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "tagged");
src.var.set("@test/c", aHash);
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst);
const tags = dst.tag.tags(aHash);
expect(tags.some((t) => t.key === "env" && t.value === "prod")).toBe(true);
});
test("2.14 import: stats report nodes/vars/tags counts", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/c", aHash);
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
const stats = await importBundle(out, dst);
expect(stats.nodes.imported).toBeGreaterThan(0);
expect(stats.nodes.skipped).toBeGreaterThanOrEqual(0);
expect(stats.vars.created + stats.vars.updated).toBeGreaterThan(0);
expect(stats.tags).toBeGreaterThanOrEqual(1);
});
test("2.15 loadBundleStore: read-only Store from tar", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const bundleStore = await loadBundleStore(out);
expect(bundleStore.cas.get(aHash)).not.toBeNull();
expect(bundleStore.cas.has(aHash)).toBe(true);
const v = bundleStore.var.get("@test/config");
expect(v?.value).toBe(aHash);
const tags = bundleStore.tag.tags(aHash);
expect(tags.some((t) => t.key === "env" && t.value === "prod")).toBe(true);
});
test("2.16 loadBundleStore: walk works against bundle store", async () => {
const src = createMemoryStore();
bootstrap(src);
const refSchema = putSchema(src, {
type: "object",
properties: { next: { type: "string", format: "ocas_ref" } },
});
const stringSchema = putSchema(src, { type: "string" });
const bHash = src.cas.put(stringSchema, "b-content");
const aHash = src.cas.put(refSchema, { next: bHash });
src.var.set("@test/root", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/root"], out);
const bundleStore = await loadBundleStore(out);
const { walk } = await import("./schema.js");
const visited: string[] = [];
walk(bundleStore, aHash, (h) => visited.push(h));
expect(visited).toContain(aHash);
expect(visited).toContain(bHash);
});
});
// ---- Tar parser (minimal POSIX/ustar reader) used by tests ----
type TarEntry = { name: string; content: Uint8Array };
function readTarEntries(buf: Buffer): TarEntry[] {
const entries: TarEntry[] = [];
let offset = 0;
while (offset + 512 <= buf.length) {
const header = buf.subarray(offset, offset + 512);
// End-of-archive: two consecutive zero blocks.
if (header.every((b) => b === 0)) break;
const name = readCString(header, 0, 100);
const sizeStr = readCString(header, 124, 12).trim();
const size = sizeStr === "" ? 0 : parseInt(sizeStr, 8);
offset += 512;
const content = buf.subarray(offset, offset + size);
entries.push({ name, content: new Uint8Array(content) });
// Pad to 512-byte boundary.
offset += Math.ceil(size / 512) * 512;
}
return entries;
}
function listTarEntries(buf: Buffer): string[] {
return readTarEntries(buf).map((e) => e.name);
}
function readCString(buf: Buffer, start: number, len: number): string {
const slice = buf.subarray(start, start + len);
let end = slice.length;
for (let i = 0; i < slice.length; i++) {
if (slice[i] === 0) {
end = i;
break;
}
}
return slice.subarray(0, end).toString("utf8");
}
// Suppress unused import warnings.
void writeFileSync;
-394
View File
@@ -1,394 +0,0 @@
import { readFileSync, writeFileSync } from "node:fs";
import { decode } from "cborg";
import { bootstrap } from "./bootstrap.js";
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
import { cborEncode } from "./cbor.js";
import { computeClosure } from "./closure.js";
import { createMemoryStore } from "./store.js";
import type { CasNode, Hash, Store, Tag } from "./types.js";
import type { Variable } from "./variable.js";
/**
* Stats returned by `exportBundle`.
*/
export type ExportStats = {
nodes: number;
vars: number;
tags: number;
};
/**
* Options for `importBundle`.
*/
export type ImportOptions = {
/** Replace the original `@scope` of each non-builtin variable with this value. */
scope?: string;
};
/**
* Stats returned by `importBundle`.
*/
export type ImportStats = {
nodes: { imported: number; skipped: number };
vars: { created: number; updated: number };
tags: number;
};
/** Import via CBOR using cborg, mirroring how FsStore decodes nodes. */
const BUILTIN_PREFIX = "@ocas/";
/**
* Resolve a single root spec (variable name OR raw hash) into a hash. Throws
* if the name does not resolve and the input is not a hash.
*/
function resolveRoot(store: Store, input: string): Hash {
if (/^[0-9A-HJKMNP-TV-Z]{13}$/.test(input)) {
if (!store.cas.has(input)) {
throw new Error(`Root hash not found in store: ${input}`);
}
return input as Hash;
}
const variants = store.var.list({ exactName: input });
const first = variants[0];
if (!first) {
throw new Error(`Root variable not found: ${input}`);
}
return first.value as Hash;
}
/**
* Compute the transitive CAS closure of `roots`, write a tar archive at
* `outputPath` containing all CAS nodes (`cas/<hash>.bin`), variables
* (`vars.jsonl`), and tags (`tags.jsonl`).
*/
export async function exportBundle(
store: Store,
roots: string[],
outputPath: string,
): Promise<ExportStats> {
// Resolve every root before computing the closure so missing names error
// early.
const rootHashes = roots.map((r) => resolveRoot(store, r));
const closure = computeClosure(store, rootHashes);
const entries: TarEntry[] = [];
// CAS nodes — one CBOR-encoded file per node, named by hash.
// Order is deterministic by sorted hash.
const sortedNodes = [...closure.nodes].sort();
for (const hash of sortedNodes) {
const node = store.cas.get(hash);
if (!node) continue;
const content = cborEncode({
type: node.type,
payload: node.payload,
timestamp: node.timestamp,
});
entries.push({ name: `cas/${hash}.bin`, content });
}
// Variables — JSON-lines.
const sortedVars = [...closure.vars].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
const varLines = sortedVars
.map((v) =>
JSON.stringify({
name: v.name,
schema: v.schema,
value: v.value,
created: v.created,
updated: v.updated,
tags: v.tags,
labels: v.labels,
}),
)
.join("\n");
entries.push({
name: "vars.jsonl",
content: new TextEncoder().encode(
varLines + (varLines.length > 0 ? "\n" : ""),
),
});
// Tags — JSON-lines, one per tag.
const tagLines: string[] = [];
const sortedTagTargets = [...closure.tags.keys()].sort();
let tagCount = 0;
for (const target of sortedTagTargets) {
const tagList = closure.tags.get(target) ?? [];
for (const t of tagList) {
tagLines.push(
JSON.stringify({
target: t.target,
key: t.key,
value: t.value,
created: t.created,
}),
);
tagCount++;
}
}
const tagText = tagLines.join("\n");
entries.push({
name: "tags.jsonl",
content: new TextEncoder().encode(
tagText + (tagText.length > 0 ? "\n" : ""),
),
});
// Pack into tar and write to disk.
const tar = packTar(entries);
writeFileSync(outputPath, tar);
return {
nodes: sortedNodes.length,
vars: sortedVars.length,
tags: tagCount,
};
}
/**
* Read a bundle tar archive from disk, returning the parsed components
* without applying them to a store.
*/
function readBundle(bundlePath: string): {
nodes: Map<Hash, CasNode>;
vars: Variable[];
tags: Tag[];
} {
const buf = readFileSync(bundlePath);
const entries = unpackTar(buf);
const nodes = new Map<Hash, CasNode>();
let vars: Variable[] = [];
let tags: Tag[] = [];
for (const entry of entries) {
if (entry.name.startsWith("cas/") && entry.name.endsWith(".bin")) {
const hash = entry.name.slice(4, -4) as Hash;
const node = decode(entry.content) as CasNode;
nodes.set(hash, node);
} else if (entry.name === "vars.jsonl") {
const text = new TextDecoder().decode(entry.content);
vars = text
.split("\n")
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as Variable);
} else if (entry.name === "tags.jsonl") {
const text = new TextDecoder().decode(entry.content);
tags = text
.split("\n")
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as Tag);
}
}
return { nodes, vars, tags };
}
/**
* Apply scope remapping to a variable name. `@ocas/*` is reserved and never
* remapped. Other names get `^@[^/]+` replaced with the new scope.
*/
function remapVarName(name: string, scope: string | undefined): string {
if (scope === undefined) return name;
if (name.startsWith(BUILTIN_PREFIX)) return name;
// Replace leading @scope with the new scope. The format is `@scope/rest`.
return name.replace(/^@[^/]+/, scope);
}
/**
* Read a bundle from disk and apply its contents to `target`.
*/
export async function importBundle(
bundlePath: string,
target: Store,
options?: ImportOptions,
): Promise<ImportStats> {
// Ensure target is bootstrapped so meta-schema is available (importing the
// meta-schema as a regular CAS node would still work since hash-equal
// self-referencing nodes dedup).
bootstrap(target);
const { nodes, vars, tags } = readBundle(bundlePath);
// Sort nodes so that meta-schema (self-referencing) is imported first,
// then types (whose `type` is the meta-schema), then leaves. The simple
// heuristic: import nodes whose `type` is already present (or self) until
// the queue stabilises.
let imported = 0;
let skipped = 0;
const remaining = new Map(nodes);
let progress = true;
while (remaining.size > 0 && progress) {
progress = false;
for (const [hash, node] of [...remaining]) {
const ready = node.type === hash || target.cas.has(node.type);
if (!ready) continue;
if (target.cas.has(hash)) {
skipped++;
} else if (node.type === hash) {
// Self-referencing meta — import via bootstrap-capable interface.
// Fall back to put if the store doesn't expose BOOTSTRAP_STORE.
const cas = target.cas as unknown as {
[k: symbol]: ((p: unknown) => Hash) | undefined;
};
const fn = cas[BOOTSTRAP_STORE];
if (fn) {
fn(node.payload);
} else {
target.cas.put(node.type, node.payload);
}
imported++;
} else {
target.cas.put(node.type, node.payload);
imported++;
}
remaining.delete(hash);
progress = true;
}
}
// If anything remains, type chains were unresolvable — import them anyway.
for (const [hash, node] of remaining) {
if (target.cas.has(hash)) {
skipped++;
} else {
target.cas.put(node.type, node.payload);
imported++;
}
}
// Variables.
let created = 0;
let updated = 0;
for (const v of vars) {
const newName = remapVarName(v.name, options?.scope);
// @ocas/* names already exist after bootstrap; if name+schema match value
// they will be silently no-op'd by the store.
const existing = target.var.get(newName, v.schema);
target.var.set(newName, v.value, {
tags: v.tags ?? {},
labels: v.labels ?? [],
});
if (existing === null) {
created++;
} else {
updated++;
}
}
// Tags. Apply each tag to its target.
for (const t of tags) {
target.tag.tag(t.target, [
t.value === null
? { op: "set", key: t.key }
: { op: "set", key: t.key, value: t.value },
]);
}
return {
nodes: { imported, skipped },
vars: { created, updated },
tags: tags.length,
};
}
/**
* Build a read-only `Store` whose contents come from a bundle tar file.
*/
export async function loadBundleStore(bundlePath: string): Promise<Store> {
const store = createMemoryStore();
// Apply the bundle's contents but suppress the bootstrap-only nodes so
// the bundle file remains the source of truth.
await importBundle(bundlePath, store);
return store;
}
// ---------------------------------------------------------------------------
// Minimal tar pack/unpack — POSIX ustar format, regular files only.
// ---------------------------------------------------------------------------
type TarEntry = { name: string; content: Uint8Array };
function packTar(entries: TarEntry[]): Buffer {
const blocks: Buffer[] = [];
for (const entry of entries) {
const header = Buffer.alloc(512);
writeString(header, entry.name, 0, 100);
writeOctal(header, 0o644, 100, 8);
writeOctal(header, 0, 108, 8);
writeOctal(header, 0, 116, 8);
writeOctal(header, entry.content.length, 124, 12);
writeOctal(header, Math.floor(Date.now() / 1000), 136, 12);
// checksum placeholder — 8 spaces, then computed.
for (let i = 0; i < 8; i++) header[148 + i] = 0x20;
header[156] = 0x30; // typeflag '0' (regular file)
writeString(header, "ustar ", 257, 8); // GNU-style ustar magic+version
let cksum = 0;
for (let i = 0; i < 512; i++) cksum += header[i] as number;
writeOctal(header, cksum, 148, 7);
header[155] = 0;
blocks.push(header);
const content = Buffer.from(entry.content);
blocks.push(content);
// Pad to 512.
const pad = (512 - (content.length % 512)) % 512;
if (pad > 0) blocks.push(Buffer.alloc(pad));
}
// End-of-archive: two zero blocks.
blocks.push(Buffer.alloc(512));
blocks.push(Buffer.alloc(512));
return Buffer.concat(blocks);
}
function unpackTar(buf: Buffer): TarEntry[] {
const entries: TarEntry[] = [];
let offset = 0;
while (offset + 512 <= buf.length) {
const header = buf.subarray(offset, offset + 512);
if (header.every((b) => b === 0)) break;
const name = readCString(header, 0, 100);
const sizeStr = readCString(header, 124, 12).trim();
const size = sizeStr === "" ? 0 : parseInt(sizeStr, 8);
offset += 512;
const content = new Uint8Array(buf.subarray(offset, offset + size));
entries.push({ name, content });
offset += Math.ceil(size / 512) * 512;
}
return entries;
}
function writeString(
buf: Buffer,
str: string,
offset: number,
len: number,
): void {
const data = Buffer.from(str, "utf8");
const n = Math.min(data.length, len);
data.copy(buf, offset, 0, n);
for (let i = n; i < len; i++) buf[offset + i] = 0;
}
function writeOctal(
buf: Buffer,
value: number,
offset: number,
len: number,
): void {
const str = value.toString(8).padStart(len - 1, "0");
writeString(buf, str, offset, len - 1);
buf[offset + len - 1] = 0;
}
function readCString(buf: Buffer, start: number, len: number): string {
const slice = buf.subarray(start, start + len);
let end = slice.length;
for (let i = 0; i < slice.length; i++) {
if (slice[i] === 0) {
end = i;
break;
}
}
return slice.subarray(0, end).toString("utf8");
}
-205
View File
@@ -1,205 +0,0 @@
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { computeClosure } from "./closure.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
describe("computeClosure", () => {
test("1.1 basic node traversal — collects A, B, C linked by ocas_ref", () => {
const store = createMemoryStore();
bootstrap(store);
const refSchema = putSchema(store, {
type: "object",
properties: {
next: { type: "string", format: "ocas_ref" },
name: { type: "string" },
},
});
const stringSchema = putSchema(store, { type: "string" });
const cHash = store.cas.put(stringSchema, "leaf-c");
const bHash = store.cas.put(refSchema, { next: cHash, name: "b" });
const aHash = store.cas.put(refSchema, { next: bHash, name: "a" });
const result = computeClosure(store, [aHash]);
expect(result.nodes.has(aHash)).toBe(true);
expect(result.nodes.has(bHash)).toBe(true);
expect(result.nodes.has(cHash)).toBe(true);
});
test("1.2 schema chain inclusion — schema and meta-schema are part of the closure", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const metaHash = aliases["@ocas/schema"] as string;
const schemaHash = putSchema(store, { type: "object" });
const nodeHash = store.cas.put(schemaHash, { foo: "bar" });
const result = computeClosure(store, [nodeHash]);
expect(result.nodes.has(nodeHash)).toBe(true);
expect(result.nodes.has(schemaHash)).toBe(true);
expect(result.nodes.has(metaHash)).toBe(true);
});
test("1.3 template variable nodes — template content is included", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"] as string;
const schemaHash = putSchema(store, { type: "object" });
const nodeHash = store.cas.put(schemaHash, { x: 1 });
// Register a template for schemaHash
const templateContent = "rendered: {{ x }}";
const contentHash = store.cas.put(stringHash, templateContent);
store.var.set(`@ocas/template/text/${schemaHash}`, contentHash);
const result = computeClosure(store, [nodeHash]);
expect(result.nodes.has(nodeHash)).toBe(true);
expect(result.nodes.has(schemaHash)).toBe(true);
expect(result.nodes.has(contentHash)).toBe(true);
const templateVarNames = result.vars.map((v) => v.name);
expect(templateVarNames).toContain(`@ocas/template/text/${schemaHash}`);
});
test("1.4 multiple roots — union of closures", () => {
const store = createMemoryStore();
bootstrap(store);
const stringSchema = putSchema(store, { type: "string" });
const aHash = store.cas.put(stringSchema, "alpha");
const bHash = store.cas.put(stringSchema, "beta");
store.var.set("@test/a", aHash);
store.var.set("@test/b", bHash);
const result = computeClosure(store, [aHash, bHash]);
expect(result.nodes.has(aHash)).toBe(true);
expect(result.nodes.has(bHash)).toBe(true);
});
test("1.5 cycle handling — terminates on self-references", () => {
const store = createMemoryStore();
bootstrap(store);
const refSchema = putSchema(store, {
type: "object",
properties: {
next: { type: "string", format: "ocas_ref" },
},
});
// Build a self-loop by hashing first then storing
const stringSchema = putSchema(store, { type: "string" });
const placeholder = store.cas.put(stringSchema, "self");
// Create a cycle A -> B -> A
const bHash = store.cas.put(refSchema, { next: placeholder });
const aHash = store.cas.put(refSchema, { next: bHash });
// Mutate B to point back to A is impossible in CAS — instead test that
// the same node is visited only once even if reached via multiple paths.
const result = computeClosure(store, [aHash, aHash]);
expect(result.nodes.has(aHash)).toBe(true);
expect(result.nodes.has(bHash)).toBe(true);
// The placeholder is reached from B
expect(result.nodes.has(placeholder)).toBe(true);
// Each node appears exactly once in the set
expect(result.nodes.size).toBeGreaterThan(0);
});
test("1.6 variables pointing into closure are collected", () => {
const store = createMemoryStore();
bootstrap(store);
const stringSchema = putSchema(store, { type: "string" });
const xHash = store.cas.put(stringSchema, "x-content");
const yHash = store.cas.put(stringSchema, "y-content");
store.var.set("@test/x", xHash);
store.var.set("@test/y", yHash);
const result = computeClosure(store, [xHash]);
const names = result.vars.map((v) => v.name);
expect(names).toContain("@test/x");
expect(names).not.toContain("@test/y");
});
test("1.7 @ocas/* builtin vars whose values are in closure are collected", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const metaHash = aliases["@ocas/schema"] as string;
const result = computeClosure(store, [metaHash]);
// @ocas/schema is a builtin var pointing to metaHash
const names = result.vars.map((v) => v.name);
expect(names).toContain("@ocas/schema");
});
test("1.8 tags on closure nodes are collected", () => {
const store = createMemoryStore();
bootstrap(store);
const stringSchema = putSchema(store, { type: "string" });
const aHash = store.cas.put(stringSchema, "tagged-a");
const bHash = store.cas.put(stringSchema, "tagged-b");
store.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
store.tag.tag(bHash, [{ op: "set", key: "env", value: "dev" }]);
const result = computeClosure(store, [aHash]);
const aTags = result.tags.get(aHash);
expect(aTags).toBeDefined();
expect(aTags?.some((t) => t.key === "env" && t.value === "prod")).toBe(
true,
);
// B is not in the closure
expect(result.tags.has(bHash)).toBe(false);
});
test("1.9 empty roots → empty closure", () => {
const store = createMemoryStore();
bootstrap(store);
const result = computeClosure(store, []);
expect(result.nodes.size).toBe(0);
expect(result.vars).toEqual([]);
expect(result.tags.size).toBe(0);
});
test("1.10 template content for any schema in closure is included", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"] as string;
// schema A has template, schema B does not — both reachable via refs
const refSchema = putSchema(store, {
type: "object",
properties: {
next: { type: "string", format: "ocas_ref" },
},
});
const innerSchema = putSchema(store, { type: "object" });
const innerNode = store.cas.put(innerSchema, { x: 1 });
const outerNode = store.cas.put(refSchema, { next: innerNode });
const tplA = store.cas.put(stringHash, "A:{{ next }}");
const tplInner = store.cas.put(stringHash, "INNER");
store.var.set(`@ocas/template/text/${refSchema}`, tplA);
store.var.set(`@ocas/template/text/${innerSchema}`, tplInner);
const result = computeClosure(store, [outerNode]);
expect(result.nodes.has(tplA)).toBe(true);
expect(result.nodes.has(tplInner)).toBe(true);
});
});
-117
View File
@@ -1,117 +0,0 @@
import { walk } from "./schema.js";
import type { Hash, Store, Tag } from "./types.js";
import type { Variable } from "./variable.js";
/**
* Result of a closure computation: the set of CAS hashes reachable from a
* set of roots, along with the variables and tags that point into the
* closure.
*/
export type ClosureResult = {
/** All CAS node hashes reachable from the roots. */
nodes: Set<Hash>;
/** Variables whose value is in the closure (excluding orphaned vars). */
vars: Variable[];
/** Tags grouped by their target hash (only targets in the closure). */
tags: Map<Hash, Tag[]>;
};
/**
* Compute the transitive closure starting from a set of root CAS hashes.
*
* The closure is a self-contained subset of a Store: every node it points
* at via `ocas_ref` fields, every schema it depends on (the meta-schema
* chain), and every template variable referencing a schema in the closure
* is included.
*
* Variables that point at hashes in the closure (after node and template
* walks) are returned. Tags whose target is in the closure are returned.
*
* Roots that do not exist in the store are silently skipped callers
* (e.g. `exportBundle`) should validate roots beforehand if strictness is
* required.
*/
export function computeClosure(store: Store, roots: Hash[]): ClosureResult {
const nodes = new Set<Hash>();
// Phase 1: walk refs from each root.
for (const root of roots) {
if (!store.cas.has(root)) continue;
walk(store, root, (hash, node) => {
nodes.add(hash);
nodes.add(node.type);
});
}
// Phase 2: walk the schema chain to include meta-schemas (e.g. @ocas/schema)
// and any other type ancestors.
const schemasToWalk = new Set<Hash>();
for (const hash of nodes) {
const node = store.cas.get(hash);
if (node) schemasToWalk.add(node.type);
}
for (const schemaHash of schemasToWalk) {
let current: Hash | null = schemaHash;
while (current !== null && !nodes.has(current)) {
nodes.add(current);
const node = store.cas.get(current);
if (!node || node.type === current) break;
current = node.type;
}
}
// Phase 3: collect template variables for each schema in the closure.
// Templates are stored as `@ocas/template/text/<schema-hash>` variables.
// If a template exists for a schema in the closure, walk its content too.
const templateVars: Variable[] = [];
// Snapshot existing schema list — we may add nodes during template walks
const initialNodes = [...nodes];
for (const hash of initialNodes) {
const templateName = `@ocas/template/text/${hash}`;
const variants = store.var.list({ exactName: templateName });
for (const variant of variants) {
templateVars.push(variant);
// Walk the template content node
walk(store, variant.value, (h, n) => {
nodes.add(h);
nodes.add(n.type);
});
// And its schema chain
const tNode = store.cas.get(variant.value);
if (tNode) {
let current: Hash | null = tNode.type;
while (current !== null && !nodes.has(current)) {
nodes.add(current);
const node = store.cas.get(current);
if (!node || node.type === current) break;
current = node.type;
}
}
}
}
// Phase 4: collect variables whose value is in the closure. Template
// variables are already collected; deduplicate.
const varKey = (v: Variable): string => `${v.name}\u0000${v.schema}`;
const seenVars = new Set<string>(templateVars.map(varKey));
const vars: Variable[] = [...templateVars];
const allVars = store.var.list();
for (const v of allVars) {
if (!nodes.has(v.value)) continue;
const key = varKey(v);
if (seenVars.has(key)) continue;
seenVars.add(key);
vars.push(v);
}
// Phase 5: collect tags for each node in the closure.
const tags = new Map<Hash, Tag[]>();
for (const hash of nodes) {
const tagList = store.tag.tags(hash);
if (tagList.length > 0) {
tags.set(hash, tagList);
}
}
return { nodes, vars, tags };
}
-216
View File
@@ -119,219 +119,3 @@ describe("GC - Variable Model Refactoring", () => {
expect(store.cas.has(hashB)).toBe(true);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Suite B: gc end-to-end with oneOf step chains (issue #93)
// ──────────────────────────────────────────────────────────────────────────────
describe("GC - oneOf step chain preservation (#93)", () => {
test("B.1 preserves a 3-step chain joined by oneOf nullable prev", async () => {
const store = createMemoryStore();
bootstrap(store);
const stepSchema = putSchema(store, {
type: "object",
properties: {
payload: { type: "string" },
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const step1 = store.cas.put(stepSchema, { payload: "a", prev: null });
const step2 = store.cas.put(stepSchema, { payload: "b", prev: step1 });
const step3 = store.cas.put(stepSchema, { payload: "c", prev: step2 });
const orphanStep = store.cas.put(stepSchema, {
payload: "orphan",
prev: null,
});
store.var.set("@test/thread/head", step3);
gc(store);
expect(store.cas.has(step1)).toBe(true);
expect(store.cas.has(step2)).toBe(true);
expect(store.cas.has(step3)).toBe(true);
expect(store.cas.has(stepSchema)).toBe(true);
expect(store.cas.has(orphanStep)).toBe(false);
});
test("B.2 preserves a chain that mixes oneOf detail refs", async () => {
const store = createMemoryStore();
bootstrap(store);
const detailSchema = putSchema(store, {
type: "object",
properties: { info: { type: "string" } },
});
const stepSchema = putSchema(store, {
type: "object",
properties: {
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
detail: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const detail1 = store.cas.put(detailSchema, { info: "d1" });
const detail2 = store.cas.put(detailSchema, { info: "d2" });
const step1 = store.cas.put(stepSchema, {
prev: null,
detail: detail1,
});
const step2 = store.cas.put(stepSchema, {
prev: step1,
detail: detail2,
});
store.var.set("@test/thread/head", step2);
gc(store);
expect(store.cas.has(step1)).toBe(true);
expect(store.cas.has(step2)).toBe(true);
expect(store.cas.has(detail1)).toBe(true);
expect(store.cas.has(detail2)).toBe(true);
});
test("B.3 preserves a workflow node referenced via oneOf from a step", async () => {
const store = createMemoryStore();
bootstrap(store);
const workflowSchema = putSchema(store, {
type: "object",
properties: { name: { type: "string" } },
});
const stepSchema = putSchema(store, {
type: "object",
properties: {
workflow: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const workflowNode = store.cas.put(workflowSchema, {
name: "solve-issue",
});
const step = store.cas.put(stepSchema, { workflow: workflowNode });
store.var.set("@test/thread/head", step);
store.var.set("@uwf/registry/solve-issue", workflowNode);
gc(store);
expect(store.cas.has(step)).toBe(true);
expect(store.cas.has(workflowNode)).toBe(true);
});
test("B.4 regression: existing anyOf traversal still works", async () => {
const store = createMemoryStore();
bootstrap(store);
const childSchema = putSchema(store, { type: "string" });
const parentSchema = putSchema(store, {
type: "object",
properties: {
child: {
anyOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const child = store.cas.put(childSchema, "child-value");
const parent = store.cas.put(parentSchema, { child });
store.var.set("@test/parent", parent);
gc(store);
expect(store.cas.has(parent)).toBe(true);
expect(store.cas.has(child)).toBe(true);
});
test("B.5 reports correct stats with oneOf chains", async () => {
const store = createMemoryStore();
bootstrap(store);
const stepSchema = putSchema(store, {
type: "object",
properties: {
payload: { type: "string" },
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const step1 = store.cas.put(stepSchema, { payload: "a", prev: null });
const step2 = store.cas.put(stepSchema, { payload: "b", prev: step1 });
const step3 = store.cas.put(stepSchema, { payload: "c", prev: step2 });
store.cas.put(stepSchema, { payload: "orphan", prev: null });
store.var.set("@test/thread/head", step3);
const stats = gc(store);
expect(stats.collected).toBe(1);
expect(stats.reachable).toBeGreaterThanOrEqual(4);
expect(stats.scanned).toBeGreaterThanOrEqual(1);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Suite C: gc preserves template content for reachable schemas (issue #93)
// ──────────────────────────────────────────────────────────────────────────────
describe("GC - template content preservation (#93)", () => {
test("C.1 preserves @ocas/template/text/<schema> content when schema is reachable", async () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"] as string;
const schemaA = putSchema(store, {
type: "object",
properties: { x: { type: "number" } },
});
const nodeA = store.cas.put(schemaA, { x: 42 });
store.var.set("@test/a", nodeA);
const tplA = store.cas.put(stringHash, "rendered: {{x}}");
store.var.set(`@ocas/template/text/${schemaA}`, tplA);
gc(store);
expect(store.cas.has(tplA)).toBe(true);
const tplVar = store.var.get(`@ocas/template/text/${schemaA}`);
expect(tplVar).not.toBeNull();
expect(tplVar?.value).toBe(tplA);
});
test("C.2 removes orphan template content for an unreachable schema", async () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"] as string;
const schemaA = putSchema(store, {
type: "object",
properties: { x: { type: "number" } },
});
// Note: do NOT bind any variable to a node typed by schemaA — schemaA is
// unreachable as a typeHash of any reachable node.
const otherSchema = putSchema(store, { type: "string" });
const otherNode = store.cas.put(otherSchema, "other");
store.var.set("@test/other", otherNode);
const tplA = store.cas.put(stringHash, "rendered: {{x}}");
store.var.set(`@ocas/template/text/${schemaA}`, tplA);
gc(store);
expect(store.cas.has(tplA)).toBe(false);
});
});
+2 -40
View File
@@ -8,17 +8,10 @@ export interface GcStats {
scanned: number; // Variables scanned as roots
}
const TEMPLATE_VAR_PREFIX = "@ocas/template/text/";
/**
* Garbage collection: mark-and-sweep algorithm
* - Roots: all variable values (global, not scoped), excluding
* `@ocas/template/text/*` variables those are added in a follow-up
* phase only when their referenced schema is itself reachable.
* - Roots: all variable values (global, not scoped)
* - Mark: recursively walk refs from roots
* - Template phase: for every reachable schema, walk the contents of its
* `@ocas/template/text/<schema>` template variable (mirrors
* `computeClosure` Phase 3).
* - Sweep: delete unmarked nodes
* - Schema preservation: schemas of reachable nodes are also marked
*/
@@ -28,13 +21,9 @@ export function gc(store: Store): GcStats {
const variables = store.var.list();
const scanned = variables.length;
// Collect unique root hashes from all variables, except template
// variables (`@ocas/template/text/*`). Template variables are processed
// in a follow-up phase so their content is preserved only when the
// referenced schema is itself reachable from non-template roots.
// Collect unique root hashes from all variables
const roots = new Set<Hash>();
for (const variable of variables) {
if (variable.name.startsWith(TEMPLATE_VAR_PREFIX)) continue;
roots.add(variable.value);
}
@@ -74,33 +63,6 @@ export function gc(store: Store): GcStats {
}
}
// Template phase: include `@ocas/template/text/<schema>` content nodes
// when their schema is in the reachable set (mirrors closure.ts Phase 3).
// Snapshot the current reachable set before walking template content so
// that template-only nodes do not transitively pull in further templates.
const reachableSnapshot = [...reachable];
for (const hash of reachableSnapshot) {
const templateName = `${TEMPLATE_VAR_PREFIX}${hash}`;
const variants = store.var.list({ exactName: templateName });
for (const variant of variants) {
walk(store, variant.value, (h, n) => {
reachable.add(h);
reachable.add(n.type);
});
// Walk the template content's schema chain too
const tNode = store.cas.get(variant.value);
if (tNode) {
let current: Hash | null = tNode.type;
while (current !== null && !reachable.has(current)) {
reachable.add(current);
const node = store.cas.get(current);
if (!node || node.type === current) break;
current = node.type;
}
}
}
}
// Preserve all self-referencing nodes (bootstrap meta-schema)
// These are nodes where type === hash
const allHashes = store.cas.listAll();
+4 -4
View File
@@ -269,7 +269,7 @@ describe("bootstrap", () => {
);
});
test("returns a map with built-in schema aliases", async () => {
test("returns a map with 30 built-in schema aliases", async () => {
const store = createMemoryStore();
const builtinSchemas = bootstrap(store);
@@ -289,7 +289,7 @@ describe("bootstrap", () => {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(Object.keys(builtinSchemas)).toHaveLength(33);
expect(Object.keys(builtinSchemas)).toHaveLength(31);
});
test("meta-schema node is stored and retrievable", async () => {
@@ -326,7 +326,7 @@ describe("bootstrap", () => {
const h2 = bootstrap(store);
expect(h1).toEqual(h2);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 24 outputs)
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(32);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 22 outputs)
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
});
});
-9
View File
@@ -1,16 +1,7 @@
export { bootstrap } from "./bootstrap.js";
export type { BootstrapCapableStore } from "./bootstrap-capable.js";
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
export {
type ExportStats,
exportBundle,
type ImportOptions,
type ImportStats,
importBundle,
loadBundleStore,
} from "./bundle.js";
export { cborEncode } from "./cbor.js";
export { type ClosureResult, computeClosure } from "./closure.js";
export {
CasNodeNotFoundError,
InvalidTagFormatError,
+20 -20
View File
@@ -598,7 +598,7 @@ describe("Suite 4: Render Flow Integration", () => {
epsilon: 0.01,
});
await expect(output.length).toBe(0);
expect(output.length).toBe(0);
} finally {
await cleanup();
}
@@ -623,13 +623,13 @@ describe("Suite 4: Render Flow Integration", () => {
);
store.var.set(`@ocas/template/text/${nodeSchema}`, template);
await expect(
renderWithTemplate(store, nodeHash, {
await expect(async () => {
await renderWithTemplate(store, nodeHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
}),
).rejects.toThrow();
});
}).toThrow();
} finally {
await cleanup();
}
@@ -905,8 +905,8 @@ describe("Suite 6: Recursive Rendering Edge Cases", () => {
});
expect(output).toContain("Item: item1");
await expect(output).toContain("Item: item2");
await expect(output).toContain("Item: item3");
expect(output).toContain("Item: item2");
expect(output).toContain("Item: item3");
} finally {
await cleanup();
}
@@ -937,7 +937,7 @@ describe("Suite 7: Error Handling & Edge Cases", () => {
epsilon: 0.01,
});
await expect(output).toBeDefined();
expect(output).toBeDefined();
} finally {
await cleanup();
}
@@ -970,13 +970,13 @@ describe("Suite 7: Error Handling & Edge Cases", () => {
);
store.var.set(`@ocas/template/text/${parentSchema}`, template);
await expect(
renderWithTemplate(store, parentHash, {
await expect(async () => {
await renderWithTemplate(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
}),
).rejects.toThrow(/decay/);
});
}).toThrow(/decay/);
} finally {
await cleanup();
}
@@ -1009,13 +1009,13 @@ describe("Suite 7: Error Handling & Edge Cases", () => {
);
store.var.set(`@ocas/template/text/${parentSchema}`, template);
await expect(
renderWithTemplate(store, parentHash, {
await expect(async () => {
await renderWithTemplate(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
}),
).rejects.toThrow();
});
}).toThrow();
} finally {
await cleanup();
}
@@ -1048,13 +1048,13 @@ describe("Suite 7: Error Handling & Edge Cases", () => {
);
store.var.set(`@ocas/template/text/${parentSchema}`, template);
await expect(
renderWithTemplate(store, parentHash, {
await expect(async () => {
await renderWithTemplate(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
}),
).rejects.toThrow(/decay/);
});
}).toThrow(/decay/);
} finally {
await cleanup();
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
function* walk(dir: string): Generator<string> {
for (const name of readdirSync(dir)) {
-79
View File
@@ -743,82 +743,3 @@ describe("bootstrap meta-schema self-reference", () => {
expect(refList).toContain(targetHash);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Suite A: collectRefs() oneOf traversal (issue #93)
// ──────────────────────────────────────────────────────────────────────────────
describe("collectRefs oneOf traversal", () => {
test("A.1 returns the ref hash from the chosen oneOf branch (string variant)", async () => {
const store = createMemoryStore();
const innerSchema = putSchema(store, { type: "string" });
const schema = putSchema(store, {
type: "object",
properties: {
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const targetHash = store.cas.put(innerSchema, "target");
const nodeHash = store.cas.put(schema, { prev: targetHash });
const node = store.cas.get(nodeHash) as CasNode;
expect(refs(store, node)).toContain(targetHash);
});
test("A.2 returns no ref when oneOf matches the null variant", async () => {
const store = createMemoryStore();
const schema = putSchema(store, {
type: "object",
properties: {
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const nodeHash = store.cas.put(schema, { prev: null });
const node = store.cas.get(nodeHash) as CasNode;
expect(refs(store, node)).toEqual([]);
});
test("A.3 traverses nested combinators inside oneOf", async () => {
const store = createMemoryStore();
const innerSchema = putSchema(store, { type: "string" });
const schema = putSchema(store, {
oneOf: [
{ type: "null" },
{
type: "object",
properties: { ref: { type: "string", format: "ocas_ref" } },
},
],
});
const targetHash = store.cas.put(innerSchema, "target");
const nodeHash = store.cas.put(schema, { ref: targetHash });
const node = store.cas.get(nodeHash) as CasNode;
expect(refs(store, node)).toContain(targetHash);
});
test("A.4 multiple ref branches in oneOf all surface", async () => {
const store = createMemoryStore();
const innerSchema = putSchema(store, { type: "string" });
const schema = putSchema(store, {
oneOf: [
{ type: "string", format: "ocas_ref" },
{ type: "string", format: "ocas_ref" },
],
});
const targetHash = store.cas.put(innerSchema, "target");
const nodeHash = store.cas.put(schema, targetHash);
const node = store.cas.get(nodeHash) as CasNode;
const result = refs(store, node);
expect(result).toContain(targetHash);
});
});
-11
View File
@@ -311,17 +311,6 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
return result;
}
// oneOf — JSON Schema requires exactly one branch to validate, but for
// ref collection we conservatively traverse every branch (the meta-schema
// accepts oneOf alongside anyOf, and we cannot statically know which
// branch the value will match). Mirrors anyOf handling.
if (Array.isArray(schema.oneOf)) {
for (const sub of schema.oneOf as JSONSchema[]) {
result.push(...collectRefs(sub, value));
}
return result;
}
// P2: allOf — each sub-schema applies to the same value
if (Array.isArray(schema.allOf)) {
for (const sub of schema.allOf as JSONSchema[]) {
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import type {
CasNode,
CasStore,
+34 -36
View File
@@ -258,171 +258,169 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
test("3.1: Reject schema with invalid type value", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
putSchema(store, { type: "garbage" }),
).rejects.toThrow();
expect(async () => putSchema(store, { type: "garbage" })).toThrow();
});
test("3.2: Reject schema with type as number", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, { type: 123 } as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.3: Reject schema with properties not an object", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
type: "object",
properties: "not-an-object",
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.4: Reject schema with required not an array", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
type: "object",
required: "name",
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.5: Reject schema with required containing non-strings", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
type: "object",
required: ["name", 123, true],
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.6: Reject schema with additionalProperties as string", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
type: "object",
additionalProperties: "yes",
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.7: Reject schema with anyOf not an array", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
anyOf: { type: "string" },
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.8: Reject schema with empty anyOf array", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () => putSchema(store, { anyOf: [] })).rejects.toThrow();
expect(async () => putSchema(store, { anyOf: [] })).toThrow();
});
test("3.9: Reject schema with items not an object", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
type: "array",
items: "string",
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.10: Reject schema with format not a string", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
type: "string",
format: 123,
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.11: Reject schema with enum not an array", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
type: "string",
enum: "red",
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.12: Reject schema with empty enum array", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, { type: "string", enum: [] }),
).rejects.toThrow();
).toThrow();
});
test("3.13: Reject schema with title not a string", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
type: "string",
title: 123,
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.14: Reject schema with description not a string", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
type: "string",
description: ["not a string"],
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.15: Reject schema with unsupported $ref keyword", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
$ref: "#/definitions/user",
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.16: Reject completely invalid data (non-object)", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, "not-a-schema" as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
test("3.17: Reject nested invalid schema in properties", async () => {
const store = new MemStore();
bootstrap(store);
await expect(async () =>
expect(async () =>
putSchema(store, {
type: "object",
properties: {
name: { type: "invalid-type" },
},
} as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
});
@@ -643,9 +641,9 @@ describe("Test Suite 7: Meta-Schema Content Validation", () => {
expect(hash2).toBeTruthy();
// Invalid type (number)
await expect(async () =>
expect(async () =>
putSchema(store, { type: 123 } as unknown as JSONSchema),
).rejects.toThrow();
).toThrow();
});
});
+28 -31
View File
@@ -1,45 +1,42 @@
# @ocas/fs
## 0.4.0 — 2026-06-07
- Lazy loading: `FsStore` scans only filenames in `nodes/` at startup (no CBOR decoding), reads each node from disk on first `get()`. Startup is O(filenames) instead of O(decoded-bytes).
- Move CAS node files from store root into `nodes/` subdirectory. Pre-existing flat-layout stores are auto-migrated on first open.
## 0.3.0 — 2026-06-03
- Migrate from `better-sqlite3` to built-in `node:sqlite` — zero native addon dependencies.
## 0.2.2 — 2026-06-03
- Lint and format fixes.
## 0.2.1 — 2026-06-03
- Migrate var/tag store from JSONL to `better-sqlite3`.
- `openStore()` now returns unified `Store` with `cas`, `var`, `tag` sub-stores.
- Migrate runtime from Bun to Node.js + pnpm.
## 0.2.0 — 2026-06-02
## 0.2.0
### Breaking Changes
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`.
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally.
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties
- `bootstrap(store)` and `putSchema(store, schema)` are now **synchronous** (were async)
- `VariableStore` class removed from `@ocas/core` — SQLite implementation moved to `@ocas/fs`
- `createVariableStore()` removed from `@ocas/core`
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally
- `@ocas/core` has zero `bun:sqlite` imports — pure TypeScript
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead
### New Features
- SQLite-backed `VarStore` and `TagStore` implementations.
- `TagStore` — first-class tags on any CAS node.
- `var-store-helpers.ts` — shared validation/history logic.
- `CasStore`, `VarStore`, `TagStore` sub-store types
- `TagStore` — first-class tags on any CAS node (not just variables)
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands
- `ocas get` and `ocas var get` now include tag info in output
- `ocas list --tag` and `ocas var list --tag` filter support
- `var-store-helpers.ts` — shared validation/history logic
- `validation.ts` — shared `validateName()` exported from core
## 0.1.2 — 2026-06-02
## 0.1.2
- Updated dependencies.
### Patch Changes
## 0.1.1 — 2026-06-02
- Updated dependencies:
- @ocas/core@0.1.2
- Updated dependencies.
## 0.1.1
## 0.1.0 — 2026-06-01
### Patch Changes
Initial release. Filesystem-backed CAS store with SQLite indexing and auto-bootstrap.
- Updated dependencies:
- @ocas/core@0.1.1
## 0.1.0
Initial release as `@ocas/fs`. Filesystem-backed CAS store with SQLite indexing and auto-bootstrap.
+20 -7
View File
@@ -4,7 +4,7 @@ Filesystem-backed CAS store.
## Overview
`@ocas/fs` implements a persistent `Store` backed by `node:sqlite` (`DatabaseSync`). Nodes are stored as CBOR blobs in SQLite tables. Stores support bootstrap via the same `BOOTSTRAP_STORE` symbol as the in-memory implementation.
`@ocas/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 `@ocas/core` for hashing, CBOR encoding, and types.
@@ -13,7 +13,7 @@ Depends on `@ocas/core` for hashing, CBOR encoding, and types.
## Installation
```bash
pnpm add @ocas/fs
bun add @ocas/fs
```
## API
@@ -21,18 +21,19 @@ pnpm add @ocas/fs
Exported from `src/index.ts`:
```typescript
function openStore(path: string): Promise<Store>;
function createFsStore(dir: string): BootstrapCapableStore;
```
Returns a unified `Store` with `cas`, `var`, and `tag` sub-stores, backed by SQLite. Bootstraps automatically on open.
Returns a `BootstrapCapableStore` from `@ocas/core`. The store loads existing `.bin` files on open and migrates or builds the type index on first use.
### Example
```typescript
import { putSchema } from "@ocas/core";
import { openStore } from "@ocas/fs";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
const store = await openStore("./my-cas-store");
const store = createFsStore("./my-cas-store");
await bootstrap(store);
const typeHash = await putSchema(store, {
type: "object",
@@ -45,6 +46,18 @@ 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 |
+3 -16
View File
@@ -1,16 +1,6 @@
{
"name": "@ocas/fs",
"version": "0.4.0",
"description": "Filesystem-backed CAS store with SQLite",
"keywords": [
"cas",
"filesystem",
"sqlite",
"storage"
],
"engines": {
"node": ">=22.5.0"
},
"version": "0.2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +15,8 @@
"src"
],
"dependencies": {
"@ocas/core": "workspace:*",
"cborg": "^4.2.3"
"cborg": "^4.2.3",
"@ocas/core": "workspace:*"
},
"repository": {
"type": "git",
@@ -36,8 +26,5 @@
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/fs",
"bugs": {
"url": "https://github.com/shazhou-ww/ocas/issues"
},
"devDependencies": {
"@types/node": "^25.9.1"
}
}
-1
View File
@@ -1,2 +1 @@
export { createSqliteVarStore } from "./sqlite-store.js";
export { createFsStore, openStore, prepareStore } from "./store.js";
+99
View File
@@ -0,0 +1,99 @@
/**
* SQLite adapter uses bun:sqlite when running under Bun,
* falls back to better-sqlite3 for Node.js.
*
* Exports a minimal interface matching the subset both libraries share.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Row = Record<string, any>;
export type Statement = {
run(...params: unknown[]): void;
get(...params: unknown[]): Row | undefined;
all(...params: unknown[]): Row[];
};
export type SqliteDb = {
exec(sql: string): void;
prepare(sql: string): Statement;
transaction<T>(fn: () => T): () => T;
close(): void;
};
const IS_BUN = typeof globalThis.Bun !== "undefined";
export function openSqlite(path: string): SqliteDb {
if (IS_BUN) {
return openBunSqlite(path);
}
return openBetterSqlite(path);
}
function openBunSqlite(path: string): SqliteDb {
// Dynamic require to avoid bundler issues
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
const { Database } = require("bun:sqlite");
const db = new Database(path);
return {
exec(sql: string) {
db.exec(sql);
},
prepare(sql: string): Statement {
const stmt = db.prepare(sql);
return {
run(...params: unknown[]) {
stmt.run(...params);
},
get(...params: unknown[]): Row | undefined {
return stmt.get(...params) ?? undefined;
},
all(...params: unknown[]): Row[] {
return stmt.all(...params);
},
};
},
transaction<T>(fn: () => T): () => T {
const wrapped = db.transaction(fn);
return wrapped;
},
close() {
db.close();
},
};
}
function openBetterSqlite(path: string): SqliteDb {
// Dynamic require to avoid bundler issues when running under Bun
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
const BetterSqlite3 = require("better-sqlite3");
const db = new BetterSqlite3(path);
return {
exec(sql: string) {
db.exec(sql);
},
prepare(sql: string): Statement {
const stmt = db.prepare(sql);
return {
run(...params: unknown[]) {
stmt.run(...params);
},
get(...params: unknown[]): Row | undefined {
return stmt.get(...params) ?? undefined;
},
all(...params: unknown[]): Row[] {
return stmt.all(...params);
},
};
},
transaction<T>(fn: () => T): () => T {
const wrapped = db.transaction(fn);
return wrapped;
},
close() {
db.close();
},
};
}
-686
View File
@@ -1,686 +0,0 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import type {
CasStore,
Hash,
HistoryEntry,
ListOptions,
Tag,
TagOp,
TagStore,
Variable,
VarListOptions,
VarSetOptions,
VarStore,
} from "@ocas/core";
import {
addNameIndex,
checkTagLabelConflict,
extractSchema,
MAX_HISTORY,
removeNameIndex,
SchemaMismatchError,
VariableNotFoundError,
type VarRecord,
validateName,
varKey,
} from "@ocas/core";
function transaction<T>(db: DatabaseSync, fn: () => T): T {
db.exec("BEGIN");
try {
const r = fn();
db.exec("COMMIT");
return r;
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
}
const DB_FILE = "_store.db";
const VARS_FILE = "_vars.jsonl";
const TAGS_FILE = "_tags.jsonl";
function openDb(dir: string): DatabaseSync {
mkdirSync(dir, { recursive: true });
const db = new DatabaseSync(join(dir, DB_FILE));
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");
return db;
}
function initVarTables(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS vars (
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
tags TEXT NOT NULL DEFAULT '{}',
labels TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (name, schema)
);
CREATE TABLE IF NOT EXISTS var_history (
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
position INTEGER NOT NULL,
set_at INTEGER NOT NULL,
PRIMARY KEY (name, schema, position),
FOREIGN KEY (name, schema) REFERENCES vars(name, schema) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_vars_name ON vars(name);
CREATE INDEX IF NOT EXISTS idx_vars_created ON vars(created);
CREATE INDEX IF NOT EXISTS idx_vars_updated ON vars(updated);
CREATE INDEX IF NOT EXISTS idx_var_history_pos_desc ON var_history(name, schema, position DESC);
`);
}
function initTagTables(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS tags (
target TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT,
created INTEGER NOT NULL,
PRIMARY KEY (target, key)
);
CREATE INDEX IF NOT EXISTS idx_tags_key ON tags(key);
CREATE INDEX IF NOT EXISTS idx_tags_key_value ON tags(key, value);
`);
}
// ── JSONL migration ──
type StoredTag = {
key: string;
value: string | null;
target: Hash;
created: number;
};
function migrateJsonlVars(db: DatabaseSync, dir: string, _cas: CasStore): void {
const path = join(dir, VARS_FILE);
if (!existsSync(path)) return;
const records = new Map<string, VarRecord>();
const byName = new Map<string, Set<string>>();
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const rec = JSON.parse(line) as VarRecord & { __op?: string };
if (rec.__op === "remove") {
const k = varKey(rec.name, rec.schema);
records.delete(k);
removeNameIndex(byName, rec.name, k);
} else {
const k = varKey(rec.name, rec.schema);
records.set(k, rec);
addNameIndex(byName, rec.name, k);
}
} catch {
// skip malformed
}
}
if (records.size === 0) return;
const insertVar = db.prepare(`
INSERT OR REPLACE INTO vars (name, schema, value, created, updated, tags, labels)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertHistory = db.prepare(`
INSERT OR REPLACE INTO var_history (name, schema, value, position, set_at)
VALUES (?, ?, ?, ?, ?)
`);
transaction(db, () => {
for (const rec of records.values()) {
insertVar.run(
rec.name,
rec.schema,
rec.value,
rec.created,
rec.updated,
JSON.stringify(rec.tags),
JSON.stringify(rec.labels),
);
for (const h of rec.history) {
insertHistory.run(rec.name, rec.schema, h.value, h.position, h.setAt);
}
}
});
}
function migrateJsonlTags(db: DatabaseSync, dir: string): void {
const path = join(dir, TAGS_FILE);
if (!existsSync(path)) return;
const byTarget = new Map<Hash, Map<string, Tag>>();
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const ent = JSON.parse(line) as
| (StoredTag & { __op?: "set" | "untag" })
| { __op: "untag"; target: Hash; key: string };
if ((ent as { __op?: string }).__op === "untag") {
const e = ent as { target: Hash; key: string };
const tm = byTarget.get(e.target);
if (tm) {
tm.delete(e.key);
if (tm.size === 0) byTarget.delete(e.target);
}
} else {
const t = ent as StoredTag;
let tm = byTarget.get(t.target);
if (!tm) {
tm = new Map();
byTarget.set(t.target, tm);
}
tm.set(t.key, {
key: t.key,
value: t.value,
target: t.target,
created: t.created,
});
}
} catch {
// skip
}
}
if (byTarget.size === 0) return;
const insertTag = db.prepare(`
INSERT OR REPLACE INTO tags (target, key, value, created)
VALUES (?, ?, ?, ?)
`);
transaction(db, () => {
for (const tm of byTarget.values()) {
for (const tag of tm.values()) {
insertTag.run(tag.target, tag.key, tag.value, tag.created);
}
}
});
}
// ── Row helpers ──
function toVariable(row: Record<string, unknown>): Variable {
return {
name: row.name as string,
schema: row.schema as Hash,
value: row.value as Hash,
created: row.created as number,
updated: row.updated as number,
tags: JSON.parse(row.tags as string) as Record<string, string>,
labels: JSON.parse(row.labels as string) as string[],
};
}
function toHistoryEntry(r: Record<string, unknown>): HistoryEntry {
return {
value: r.value as Hash,
position: r.position as number,
setAt: r.set_at as number,
};
}
function toTag(r: Record<string, unknown>, target: Hash): Tag {
return {
key: r.key as string,
value: r.value as string | null,
target,
created: r.created as number,
};
}
// ── Main factory ──
export function createSqliteVarStore(
dir: string,
cas: CasStore,
): { var: VarStore; tag: TagStore; close: () => void } {
const db = openDb(dir);
initVarTables(db);
initTagTables(db);
// Migrate JSONL if present (one-time, idempotent)
migrateJsonlVars(db, dir, cas);
migrateJsonlTags(db, dir);
let closed = false;
// ── Prepared statements (var) ──
const stmtGetVar = db.prepare(
"SELECT * FROM vars WHERE name = ? AND schema = ?",
);
const stmtGetByName = db.prepare("SELECT * FROM vars WHERE name = ?");
const stmtInsertVar = db.prepare(`
INSERT INTO vars (name, schema, value, created, updated, tags, labels)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const stmtUpdateVar = db.prepare(`
UPDATE vars SET value = ?, updated = ?, tags = ?, labels = ?
WHERE name = ? AND schema = ?
`);
const stmtDeleteVar = db.prepare(
"DELETE FROM vars WHERE name = ? AND schema = ?",
);
const stmtDeleteVarByName = db.prepare("DELETE FROM vars WHERE name = ?");
const stmtInsertHistory = db.prepare(`
INSERT INTO var_history (name, schema, value, position, set_at)
VALUES (?, ?, ?, ?, ?)
`);
const stmtGetHistory = db.prepare(
"SELECT value, position, set_at FROM var_history WHERE name = ? AND schema = ? ORDER BY position DESC",
);
const stmtMaxPosition = db.prepare(
"SELECT MAX(position) as max_pos FROM var_history WHERE name = ? AND schema = ?",
);
const stmtDeleteOldHistory = db.prepare(
"DELETE FROM var_history WHERE name = ? AND schema = ? AND position NOT IN (SELECT position FROM var_history WHERE name = ? AND schema = ? ORDER BY position DESC LIMIT ?)",
);
// ── Prepared statements (tag) ──
const stmtUpsertTag = db.prepare(`
INSERT INTO tags (target, key, value, created) VALUES (?, ?, ?, ?)
ON CONFLICT(target, key) DO UPDATE SET value = excluded.value
`);
const stmtDeleteTag = db.prepare(
"DELETE FROM tags WHERE target = ? AND key = ?",
);
const stmtGetTagsByTarget = db.prepare(
"SELECT * FROM tags WHERE target = ? ORDER BY key",
);
const stmtGetTagsByKey = db.prepare(
"SELECT target, key, value, created FROM tags WHERE key = ? ORDER BY created ASC",
);
const stmtGetTagsByKeyValue = db.prepare(
"SELECT target, key, value, created FROM tags WHERE key = ? AND value = ? ORDER BY created ASC",
);
// ── Transactional helpers ──
function txnSetVar(
name: string,
schema: Hash,
hash: Hash,
now: number,
tagsJson: string,
labelsJson: string,
isNew: boolean,
valueChanged: boolean,
): void {
transaction(db, () => {
if (isNew) {
stmtInsertVar.run(name, schema, hash, now, now, tagsJson, labelsJson);
stmtInsertHistory.run(name, schema, hash, 0, now);
} else if (valueChanged) {
const maxRow = stmtMaxPosition.get(name, schema) as {
max_pos: number | null;
};
const nextPos = (maxRow.max_pos ?? -1) + 1;
stmtInsertHistory.run(name, schema, hash, nextPos, now);
stmtDeleteOldHistory.run(name, schema, name, schema, MAX_HISTORY);
stmtUpdateVar.run(hash, now, tagsJson, labelsJson, name, schema);
} else {
stmtUpdateVar.run(hash, now, tagsJson, labelsJson, name, schema);
}
});
}
function txnTagOps(target: Hash, operations: TagOp[], now: number): void {
transaction(db, () => {
for (const op of operations) {
if (op.op === "set") {
// Use ON CONFLICT to preserve created time — but we need existing created
const existing = db
.prepare("SELECT created FROM tags WHERE target = ? AND key = ?")
.get(target, op.key) as { created: number } | undefined;
const created = existing?.created ?? now;
stmtUpsertTag.run(target, op.key, op.value ?? null, created);
} else {
stmtDeleteTag.run(target, op.key);
}
}
});
}
function txnUntag(target: Hash, keys: string[]): void {
transaction(db, () => {
for (const k of keys) {
stmtDeleteTag.run(target, k);
}
});
}
// ── VarStore implementation ──
const varStore: VarStore = {
set(name: string, hash: Hash, options?: VarSetOptions): Variable {
validateName(name);
const schema = extractSchema(cas, hash);
const existing = stmtGetVar.get(name, schema) as
| Record<string, unknown>
| undefined;
const now = Date.now();
if (existing) {
const v = toVariable(existing);
const tags = options?.tags ?? v.tags;
const labels = options?.labels ?? v.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const valueChanged = v.value !== hash;
const newTags = options !== undefined ? tags : v.tags;
const newLabels = options !== undefined ? labels : v.labels;
if (valueChanged || options !== undefined) {
txnSetVar(
name,
schema,
hash,
valueChanged ? now : v.updated,
JSON.stringify(newTags),
JSON.stringify(newLabels),
false,
valueChanged,
);
}
return {
name,
schema,
value: hash,
created: v.created,
updated: valueChanged ? now : v.updated,
tags: { ...newTags },
labels: [...newLabels],
};
}
// New variable
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
checkTagLabelConflict(tags, labels);
txnSetVar(
name,
schema,
hash,
now,
JSON.stringify(tags),
JSON.stringify(labels),
true,
false,
);
return {
name,
schema,
value: hash,
created: now,
updated: now,
tags: { ...tags },
labels: [...labels],
};
},
get(name: string, schema?: Hash): Variable | null {
if (schema !== undefined) {
const row = stmtGetVar.get(name, schema) as
| Record<string, unknown>
| undefined;
return row ? toVariable(row) : null;
}
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
if (rows.length !== 1) return null;
return toVariable(rows[0]!);
},
remove(name: string, schema?: Hash): Variable[] {
if (schema !== undefined) {
const row = stmtGetVar.get(name, schema) as
| Record<string, unknown>
| undefined;
if (!row) return [];
const v = toVariable(row);
stmtDeleteVar.run(name, schema);
return [v];
}
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
if (rows.length === 0) return [];
const removed = rows.map(toVariable);
stmtDeleteVarByName.run(name);
return removed;
},
update(name: string, hash: Hash, options?: VarSetOptions): Variable {
validateName(name);
const newSchema = extractSchema(cas, hash);
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
if (rows.length === 0) throw new VariableNotFoundError(name, newSchema);
const existing = stmtGetVar.get(name, newSchema) as
| Record<string, unknown>
| undefined;
if (!existing) {
const first = toVariable(rows[0]!);
throw new SchemaMismatchError(first.schema, newSchema);
}
const v = toVariable(existing);
const now = Date.now();
const tags = options?.tags ?? v.tags;
const labels = options?.labels ?? v.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const valueChanged = v.value !== hash;
const newTags = options !== undefined ? tags : v.tags;
const newLabels = options !== undefined ? labels : v.labels;
if (valueChanged || options !== undefined) {
txnSetVar(
name,
newSchema,
hash,
valueChanged ? now : v.updated,
JSON.stringify(newTags),
JSON.stringify(newLabels),
false,
valueChanged,
);
}
return {
name,
schema: newSchema,
value: hash,
created: v.created,
updated: valueChanged ? now : v.updated,
tags: { ...newTags },
labels: [...newLabels],
};
},
list(options?: VarListOptions): Variable[] {
if (
options?.namePrefix !== undefined &&
options?.exactName !== undefined
) {
throw new Error(
"namePrefix and exactName are mutually exclusive - cannot specify both",
);
}
const limit = options?.limit;
if (limit !== undefined && limit <= 0) return [];
// Build dynamic query
const conditions: string[] = [];
const params: (string | number | null)[] = [];
if (options?.exactName !== undefined) {
conditions.push("name = ?");
params.push(options.exactName);
}
if (options?.namePrefix !== undefined) {
conditions.push("name LIKE ? ESCAPE '\\'");
const escaped = options.namePrefix
.replace(/\\/g, "\\\\")
.replace(/%/g, "\\%")
.replace(/_/g, "\\_");
params.push(`${escaped}%`);
}
if (options?.schema !== undefined) {
conditions.push("schema = ?");
params.push(options.schema);
}
const sortCol = options?.sort === "updated" ? "updated" : "created";
const sortDir = options?.desc ? "DESC" : "ASC";
const where =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
// Post-filter by tags and labels (stored as JSON)
const filterTags = options?.tags ?? {};
const filterLabels = options?.labels ?? [];
const needsPostFilter =
Object.keys(filterTags).length > 0 || filterLabels.length > 0;
// When post-filtering, fetch all matching rows (no SQL LIMIT)
// then apply limit/offset after filtering
let sql: string;
if (needsPostFilter) {
sql = `SELECT * FROM vars ${where} ORDER BY ${sortCol} ${sortDir}, name ASC`;
} else {
sql = `SELECT * FROM vars ${where} ORDER BY ${sortCol} ${sortDir}, name ASC`;
if (limit !== undefined || (options?.offset ?? 0) > 0) {
sql += ` LIMIT ${limit ?? -1} OFFSET ${options?.offset ?? 0}`;
}
}
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
if (!needsPostFilter) return rows.map(toVariable);
let results: Variable[] = [];
for (const row of rows) {
const v = toVariable(row);
let ok = true;
for (const [tk, tv] of Object.entries(filterTags)) {
if (v.tags[tk] !== tv) {
ok = false;
break;
}
}
if (!ok) continue;
for (const lb of filterLabels) {
if (!v.labels.includes(lb)) {
ok = false;
break;
}
}
if (ok) results.push(v);
}
// Apply limit/offset after post-filter
const offset = options?.offset ?? 0;
if (offset > 0) results = results.slice(offset);
if (limit !== undefined) results = results.slice(0, limit);
return results;
},
history(name: string, schema?: Hash): HistoryEntry[] {
if (schema !== undefined) {
return (
stmtGetHistory.all(name, schema) as Record<string, unknown>[]
).map(toHistoryEntry);
}
const vars = stmtGetByName.all(name) as Record<string, unknown>[];
if (vars.length !== 1) return [];
const v = vars[0]!;
return (
stmtGetHistory.all(v.name as string, v.schema as string) as Record<
string,
unknown
>[]
).map(toHistoryEntry);
},
close(): void {
if (closed) return;
closed = true;
db.close();
},
};
// ── TagStore implementation ──
const tagStore: TagStore = {
tag(target: Hash, operations: TagOp[]): Tag[] {
const now = Date.now();
txnTagOps(target, operations, now);
return (stmtGetTagsByTarget.all(target) as Record<string, unknown>[]).map(
(r) => toTag(r, target),
);
},
untag(target: Hash, keys: string[]): void {
txnUntag(target, keys);
},
tags(target: Hash): Tag[] {
return (stmtGetTagsByTarget.all(target) as Record<string, unknown>[]).map(
(r) => toTag(r, target),
);
},
listByTag(tag: string, options?: ListOptions): Hash[] {
let key = tag;
let value: string | null | undefined;
const eqIdx = tag.indexOf("=");
if (eqIdx >= 0) {
key = tag.slice(0, eqIdx);
value = tag.slice(eqIdx + 1);
}
// Build SQL with sort/limit/offset pushed down
const sortCol = "created"; // tags only have created
const sortDir = options?.desc ? "DESC" : "ASC";
const offset = options?.offset ?? 0;
const limit = options?.limit;
let sql: string;
const params: (string | number | null)[] = [key];
if (value !== undefined) {
sql = `SELECT target FROM tags WHERE key = ? AND value = ? ORDER BY ${sortCol} ${sortDir}`;
params.push(value);
} else {
sql = `SELECT target FROM tags WHERE key = ? ORDER BY ${sortCol} ${sortDir}`;
}
if (limit !== undefined || offset > 0) {
sql += ` LIMIT ${limit ?? -1} OFFSET ${offset}`;
}
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
return rows.map((r) => r.target as Hash);
},
};
return {
var: varStore,
tag: tagStore,
close: () => {
if (closed) return;
closed = true;
db.close();
},
};
}
+2 -539
View File
@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
@@ -18,7 +17,6 @@ import {
computeSelfHash,
verify,
} from "@ocas/core";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createFsStore, openStore } from "./store.js";
@@ -69,7 +67,7 @@ describe("createFsStore – init and bootstrap", () => {
const h2 = bootstrap(store);
expect(h1).toEqual(h2);
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(32);
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
});
});
@@ -590,538 +588,3 @@ describe("openStore – Store shape", () => {
expect(typeof store.tag.listByTag).toBe("function");
});
});
// ──────────────────────────────────────────────────────────────────────────────
// nodes/ subdirectory layout (#84)
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – nodes/ subdirectory layout", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// A. New layout – nodes written to nodes/ subdirectory
test("A1. put() writes .bin file to dir/nodes/, not dir/", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
const hash = await store.put(typeHash, { x: 1 });
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(true);
expect(existsSync(join(dir, `${hash}.bin`))).toBe(false);
});
test("A2. putSelfReferencing (BOOTSTRAP_STORE) writes to dir/nodes/", async () => {
const store = createFsStore(dir);
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(true);
expect(existsSync(join(dir, `${hash}.bin`))).toBe(false);
});
test("A3. nodes/ directory auto-created on first put", async () => {
expect(existsSync(join(dir, "nodes"))).toBe(false);
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
await store.put(typeHash, { x: 1 });
expect(existsSync(join(dir, "nodes"))).toBe(true);
expect(statSync(join(dir, "nodes")).isDirectory()).toBe(true);
});
// B. Round-trip with new layout
test("B1. second createFsStore instance reads nodes from dir/nodes/", async () => {
const typeHash = await computeSelfHash({ name: "B1" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { msg: "hello" });
const h2 = await store1.put(typeHash, { msg: "world" });
// Confirm files are in nodes/, not root
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
expect(existsSync(join(dir, "nodes", `${h2}.bin`))).toBe(true);
const store2 = createFsStore(dir);
expect(store2.has(h1)).toBe(true);
expect(store2.has(h2)).toBe(true);
expect(store2.listByType(typeHash)).toHaveLength(2);
});
test("B2. openStore round-trip: bootstrap + put + reload all intact", async () => {
const store1 = await openStore(dir);
const schemas1 = bootstrap(store1);
const schemaHash = schemas1["@ocas/schema"] ?? "";
const typeHash = await computeSelfHash({ name: "B2" });
const userHash = store1.cas.put(typeHash, { x: 42 });
// All node files should be in nodes/
const nodeEntries = readdirSync(join(dir, "nodes"));
expect(nodeEntries.some((e) => e === `${schemaHash}.bin`)).toBe(true);
expect(nodeEntries.some((e) => e === `${userHash}.bin`)).toBe(true);
const store2 = await openStore(dir);
expect(store2.cas.has(schemaHash)).toBe(true);
expect(store2.cas.has(userHash)).toBe(true);
});
// C. Migration from old flat layout
test("C1. old-layout .bin files in dir/ root are moved to dir/nodes/ on createFsStore", async () => {
// Manually build an "old" layout by writing nodes via a fresh store first
// then renaming files from nodes/ back to root, simulating pre-#84 stores.
const typeHash = await computeSelfHash({ name: "C1" });
const tmp = createFsStore(dir);
const h1 = await tmp.put(typeHash, { i: 1 });
const h2 = await tmp.put(typeHash, { i: 2 });
// Simulate old flat layout: move .bin files from nodes/ to root
const nodesDir = join(dir, "nodes");
for (const f of readdirSync(nodesDir)) {
renameSync(join(nodesDir, f), join(dir, f));
}
rmSync(nodesDir, { recursive: true, force: true });
expect(existsSync(join(dir, `${h1}.bin`))).toBe(true);
expect(existsSync(join(dir, `${h2}.bin`))).toBe(true);
expect(existsSync(nodesDir)).toBe(false);
// Now open the store; migration should run
const _store = createFsStore(dir);
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
expect(existsSync(join(dir, "nodes", `${h2}.bin`))).toBe(true);
});
test("C2. after migration, no .bin files remain in dir/ root", async () => {
const typeHash = await computeSelfHash({ name: "C2" });
const tmp = createFsStore(dir);
await tmp.put(typeHash, { i: 1 });
await tmp.put(typeHash, { i: 2 });
// Simulate old flat layout
const nodesDir = join(dir, "nodes");
for (const f of readdirSync(nodesDir)) {
renameSync(join(nodesDir, f), join(dir, f));
}
rmSync(nodesDir, { recursive: true, force: true });
const _store = createFsStore(dir);
const rootEntries = readdirSync(dir);
const binFilesInRoot = rootEntries.filter((e) => e.endsWith(".bin"));
expect(binFilesInRoot).toEqual([]);
});
test("C3. after migration, all nodes accessible via get() and listByType()", async () => {
const typeHash = await computeSelfHash({ name: "C3" });
const tmp = createFsStore(dir);
const h1 = await tmp.put(typeHash, { i: 1 });
const h2 = await tmp.put(typeHash, { i: 2 });
const h3 = await tmp.put(typeHash, { i: 3 });
// Simulate old flat layout: move .bin to root, drop _index so we re-build
const nodesDir = join(dir, "nodes");
for (const f of readdirSync(nodesDir)) {
renameSync(join(nodesDir, f), join(dir, f));
}
rmSync(nodesDir, { recursive: true, force: true });
rmSync(join(dir, "_index"), { recursive: true, force: true });
const store = createFsStore(dir);
expect(store.has(h1)).toBe(true);
expect(store.has(h2)).toBe(true);
expect(store.has(h3)).toBe(true);
const listed = store.listByType(typeHash).map((e) => e.hash);
expect(listed).toHaveLength(3);
expect(listed).toContain(h1);
expect(listed).toContain(h2);
expect(listed).toContain(h3);
});
test("C4. migration is idempotent: re-opening already-migrated store is no-op", async () => {
const typeHash = await computeSelfHash({ name: "C4" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { i: 1 });
await store1.put(typeHash, { i: 2 });
const nodesDirBefore = readdirSync(join(dir, "nodes")).sort();
const rootEntriesBefore = readdirSync(dir).sort();
// Re-open — should be a no-op (no migration occurs)
const _store2 = createFsStore(dir);
const nodesDirAfter = readdirSync(join(dir, "nodes")).sort();
const rootEntriesAfter = readdirSync(dir).sort();
expect(nodesDirAfter).toEqual(nodesDirBefore);
expect(rootEntriesAfter).toEqual(rootEntriesBefore);
// Sanity: file from before migration is still in nodes/
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
});
test("C5. openStore on old layout: migrates, bootstraps, put/get all work", async () => {
// Build an "old" store: bootstrap then move .bin to root
const store1 = await openStore(dir);
const schemas1 = bootstrap(store1);
const schemaHash = schemas1["@ocas/schema"] ?? "";
const nodesDir = join(dir, "nodes");
for (const f of readdirSync(nodesDir)) {
renameSync(join(nodesDir, f), join(dir, f));
}
rmSync(nodesDir, { recursive: true, force: true });
expect(existsSync(join(dir, `${schemaHash}.bin`))).toBe(true);
// Re-open with openStore — should migrate + bootstrap idempotently
const store2 = await openStore(dir);
expect(store2.cas.has(schemaHash)).toBe(true);
expect(existsSync(join(dir, "nodes", `${schemaHash}.bin`))).toBe(true);
expect(existsSync(join(dir, `${schemaHash}.bin`))).toBe(false);
// put/get works after migration
const typeHash = await computeSelfHash({ name: "C5" });
const newHash = store2.cas.put(typeHash, { hello: "world" });
expect(store2.cas.has(newHash)).toBe(true);
expect(existsSync(join(dir, "nodes", `${newHash}.bin`))).toBe(true);
});
// D. Delete
test("D1. delete() removes dir/nodes/<hash>.bin (not dir/<hash>.bin)", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "D1" });
const hash = await store.put(typeHash, { x: 1 });
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(true);
const removed = store.delete(hash);
expect(removed).toBe(true);
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(false);
expect(existsSync(join(dir, `${hash}.bin`))).toBe(false);
expect(store.has(hash)).toBe(false);
});
// E. Metadata location unchanged
test("E1. _index/ stays in dir/ (not inside nodes/)", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "E1" });
await store.put(typeHash, { x: 1 });
expect(existsSync(join(dir, "_index"))).toBe(true);
expect(statSync(join(dir, "_index")).isDirectory()).toBe(true);
expect(existsSync(join(dir, "nodes", "_index"))).toBe(false);
});
test("E2. _store.db stays in dir/ (not inside nodes/)", async () => {
const store = await openStore(dir);
const typeHash = await computeSelfHash({ name: "E2" });
store.cas.put(typeHash, { x: 1 });
expect(existsSync(join(dir, "_store.db"))).toBe(true);
expect(existsSync(join(dir, "nodes", "_store.db"))).toBe(false);
});
// F. Index rebuild with new layout
test("F1. removing _index/ then re-opening rebuilds index from dir/nodes/ .bin files", async () => {
const typeHash = await computeSelfHash({ name: "F1" });
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 });
expect(existsSync(join(dir, "_index"))).toBe(false);
const store2 = createFsStore(dir);
const list = store2.listByType(typeHash).map((e) => e.hash);
expect(list).toHaveLength(2);
expect(list).toContain(h1);
expect(list).toContain(h2);
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
// sanity: .bin files still in nodes/
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
expect(existsSync(join(dir, "nodes", `${h2}.bin`))).toBe(true);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Lazy loading (#85)
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – lazy loading (#85)", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("L1. createFsStore does NOT CBOR-decode nodes at startup", async () => {
const typeHash = await computeSelfHash({ name: "L1" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { i: 1 });
const h2 = await store1.put(typeHash, { i: 2 });
const h3 = await store1.put(typeHash, { i: 3 });
// Corrupt h2 by overwriting its .bin file with garbage CBOR
const corruptedPath = join(dir, "nodes", `${h2}.bin`);
writeFileSync(corruptedPath, Buffer.from([0xff, 0xfe, 0xfd, 0xfc]));
// Opening the store should NOT throw, even though h2 is corrupted —
// because nothing is decoded at startup.
const store2 = createFsStore(dir);
// has() should return true for all three (filename-based)
expect(store2.has(h1)).toBe(true);
expect(store2.has(h2)).toBe(true);
expect(store2.has(h3)).toBe(true);
// listAll() reads filenames, so all three appear
const all = store2.listAll();
expect(all).toContain(h1);
expect(all).toContain(h2);
expect(all).toContain(h3);
// Non-corrupted nodes load fine
expect(store2.get(h1)).not.toBeNull();
expect(store2.get(h3)).not.toBeNull();
// Corrupted node fails to load (returns null)
expect(store2.get(h2)).toBeNull();
});
test("L2. get() loads node from disk on demand (cache miss)", async () => {
const typeHash = await computeSelfHash({ name: "L2" });
const store1 = createFsStore(dir);
const hash = await store1.put(typeHash, { value: 42, label: "answer" });
const original = store1.get(hash) as CasNode;
// Lazy-load instance
const store2 = createFsStore(dir);
const loaded1 = store2.get(hash) as CasNode;
expect(loaded1.type).toBe(typeHash);
expect(loaded1.payload).toEqual({ value: 42, label: "answer" });
expect(loaded1.timestamp).toBe(original.timestamp);
// Second get should return the same data (from cache)
const loaded2 = store2.get(hash) as CasNode;
expect(loaded2).toEqual(loaded1);
});
test("L3. has() works without loading node data", async () => {
const typeHash = await computeSelfHash({ name: "L3" });
const store1 = createFsStore(dir);
const hashes: string[] = [];
for (let i = 0; i < 5; i++) {
hashes.push(await store1.put(typeHash, { i }));
}
const store2 = createFsStore(dir);
for (const hash of hashes) {
expect(store2.has(hash)).toBe(true);
}
// Non-existent hash returns false
expect(store2.has("0000000000000")).toBe(false);
});
test("L4. listAll() returns hashes from filenames without decoding", async () => {
const typeHash = await computeSelfHash({ name: "L4" });
const store1 = createFsStore(dir);
const realHashes: string[] = [];
for (let i = 0; i < 3; i++) {
realHashes.push(await store1.put(typeHash, { i }));
}
// Add a corrupted .bin file with valid filename but garbage content
const corruptedHash = "ABCDEFGHJKMNP";
writeFileSync(
join(dir, "nodes", `${corruptedHash}.bin`),
Buffer.from([0xff, 0xee, 0xdd]),
);
const store2 = createFsStore(dir);
const all = store2.listAll();
expect(all).toHaveLength(realHashes.length + 1);
for (const h of realHashes) {
expect(all).toContain(h);
}
expect(all).toContain(corruptedHash);
// Real nodes still readable
for (const h of realHashes) {
expect(store2.get(h)).not.toBeNull();
}
// Corrupted one returns null
expect(store2.get(corruptedHash)).toBeNull();
});
test("L5. put() makes node immediately available without re-reading disk", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "L5" });
const hash = await store.put(typeHash, { written: true });
// Immediately available via get(), has(), and listAll()
const node = store.get(hash) as CasNode;
expect(node.type).toBe(typeHash);
expect(node.payload).toEqual({ written: true });
expect(store.has(hash)).toBe(true);
expect(store.listAll()).toContain(hash);
});
test("L6. delete() removes node from cache and disk", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "L6" });
const hash = await store.put(typeHash, { temporary: true });
// populate cache by getting once
expect(store.get(hash)).not.toBeNull();
expect(store.delete(hash)).toBe(true);
expect(store.get(hash)).toBeNull();
expect(store.has(hash)).toBe(false);
expect(store.listAll()).not.toContain(hash);
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(false);
});
test("L7. listByType works with lazy loading (loads timestamps on demand)", async () => {
const typeA = await computeSelfHash({ name: "typeA-L7" });
const typeB = await computeSelfHash({ name: "typeB-L7" });
const store1 = createFsStore(dir);
const aHashes: string[] = [];
for (let i = 0; i < 3; i++) {
aHashes.push(await store1.put(typeA, { i }));
}
const bHashes: string[] = [];
for (let i = 0; i < 2; i++) {
bHashes.push(await store1.put(typeB, { i }));
}
const store2 = createFsStore(dir);
const aList = store2.listByType(typeA);
expect(aList).toHaveLength(3);
for (const e of aList) {
expect(aHashes).toContain(e.hash);
expect(typeof e.created).toBe("number");
expect(e.created).toBeGreaterThan(0);
}
const bList = store2.listByType(typeB);
expect(bList).toHaveLength(2);
expect(store2.listByType("0000000000000")).toEqual([]);
});
test("L8. listMeta works with lazy loading", async () => {
const store1 = createFsStore(dir);
const m1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L8a" });
const m2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L8b" });
const store2 = createFsStore(dir);
const meta = store2.listMeta();
const metaHashes = meta.map((e) => e.hash);
expect(metaHashes).toHaveLength(2);
expect(metaHashes).toContain(m1);
expect(metaHashes).toContain(m2);
for (const e of meta) {
expect(typeof e.created).toBe("number");
expect(e.created).toBeGreaterThan(0);
}
});
test("L9. listSchemas works with lazy loading", async () => {
const store1 = createFsStore(dir);
const m = await store1[BOOTSTRAP_STORE]({ type: "object" });
const s1 = await store1.put(m, { type: "string" });
const s2 = await store1.put(m, { type: "number" });
const store2 = createFsStore(dir);
const schemas = store2.listSchemas().map((e) => e.hash);
expect(schemas).toHaveLength(3);
expect(schemas).toContain(m);
expect(schemas).toContain(s1);
expect(schemas).toContain(s2);
});
test("L10. index migration still works with lazy loading", async () => {
const typeHash = await computeSelfHash({ name: "L10" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { i: 1 });
const h2 = await store1.put(typeHash, { i: 2 });
rmSync(join(dir, "_index"), { recursive: true, force: true });
// Re-open: should rebuild type index by scanning + decoding nodes on disk
const store2 = createFsStore(dir);
const list = store2.listByType(typeHash).map((e) => e.hash);
expect(list).toHaveLength(2);
expect(list).toContain(h1);
expect(list).toContain(h2);
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
// Re-open again: index already on disk, no re-scan needed
const store3 = createFsStore(dir);
const list3 = store3.listByType(typeHash).map((e) => e.hash);
expect(list3).toHaveLength(2);
});
test("L11. meta migration still works with lazy loading", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L11a" });
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L11b" });
const metaPath = join(dir, "_index", "_meta");
rmSync(metaPath, { force: true });
expect(existsSync(metaPath)).toBe(false);
const store2 = createFsStore(dir);
const meta = store2.listMeta().map((e) => e.hash);
expect(meta).toHaveLength(2);
expect(meta).toContain(h1);
expect(meta).toContain(h2);
expect(existsSync(metaPath)).toBe(true);
});
test("L12. bootstrap round-trip works with lazy store", async () => {
const store1 = await openStore(dir);
const schemas1 = bootstrap(store1);
const typeHash = await computeSelfHash({ name: "L12-user" });
const userHash = store1.cas.put(typeHash, { user: "data" });
const store2 = await openStore(dir);
// All bootstrap schemas accessible
for (const name of [
"@ocas/schema",
"@ocas/string",
"@ocas/number",
"@ocas/object",
"@ocas/array",
"@ocas/bool",
]) {
const h = schemas1[name] as string;
expect(store2.cas.has(h)).toBe(true);
expect(store2.cas.get(h)).not.toBeNull();
}
// User data still accessible
expect(store2.cas.has(userHash)).toBe(true);
const userNode = store2.cas.get(userHash) as CasNode;
expect(userNode.payload).toEqual({ user: "data" });
});
});
+74 -146
View File
@@ -27,68 +27,32 @@ import {
type Store,
} from "@ocas/core";
import { decode } from "cborg";
import { createSqliteVarStore } from "./sqlite-store.js";
import { createFsTagStore, createFsVarStoreFor } from "./var-store.js";
const INDEX_DIR = "_index";
const META_FILE = "_meta";
const NODES_DIR = "nodes";
// Initialise the xxhash WASM instance once at module load so the FS CAS
// store can use the synchronous hashing functions.
await initHasher();
/**
* Migrate any pre-#84 flat-layout `.bin` files at the store root into the
* `nodes/` subdirectory. Idempotent does nothing if no `.bin` files are
* present at the root.
*/
function migrateFlatLayoutToNodes(dir: string): void {
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
const binFiles = entries.filter((name) => name.endsWith(".bin"));
if (binFiles.length === 0) return;
const nodesDir = join(dir, NODES_DIR);
mkdirSync(nodesDir, { recursive: true });
for (const name of binFiles) {
renameSync(join(dir, name), join(nodesDir, name));
}
}
/**
* Scan `nodes/` directory for `.bin` filenames and return the set of hashes
* present on disk. Does NOT read or decode any node content this is the
* cheap O(n) startup operation that replaces the legacy full-load.
*/
function loadHashSet(dir: string): Set<Hash> {
const hashes = new Set<Hash>();
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return hashes;
}
for (const name of entries) {
if (!name.endsWith(".bin")) continue;
hashes.add(name.slice(0, -4) as Hash);
}
return hashes;
}
/**
* Read and CBOR-decode a single node from disk. Returns `null` if the file
* is missing or its content is corrupted.
*/
function readNodeFromDisk(nodesDir: string, hash: Hash): CasNode | null {
try {
const buf = readFileSync(join(nodesDir, `${hash}.bin`));
return decode(new Uint8Array(buf)) as CasNode;
} catch {
return null;
const hash = name.slice(0, -4) as Hash;
try {
const buf = readFileSync(join(dir, name));
const node = decode(new Uint8Array(buf)) as CasNode;
data.set(hash, node);
} catch {
// skip corrupted files
}
}
}
@@ -116,19 +80,9 @@ function loadTypeIndex(indexDir: string): Map<Hash, Hash[]> {
return typeIndex;
}
/**
* Migration helper: scan all `.bin` files on disk, decoding each one to read
* its `type` field, and rebuild the type index. Used only when `_index/` is
* missing a one-time cost.
*/
function buildTypeIndexFromDisk(
nodesDir: string,
hashSet: Set<Hash>,
): Map<Hash, Hash[]> {
function buildTypeIndexFromNodes(data: Map<Hash, CasNode>): Map<Hash, Hash[]> {
const typeIndex = new Map<Hash, Hash[]>();
for (const hash of hashSet) {
const node = readNodeFromDisk(nodesDir, hash);
if (!node) continue;
for (const [hash, node] of data) {
const list = typeIndex.get(node.type) ?? [];
list.push(hash);
typeIndex.set(node.type, list);
@@ -146,12 +100,11 @@ function writeTypeIndex(indexDir: string, typeIndex: Map<Hash, Hash[]>): void {
function loadOrMigrateTypeIndex(
dir: string,
nodesDir: string,
hashSet: Set<Hash>,
data: Map<Hash, CasNode>,
): Map<Hash, Hash[]> {
const indexDir = join(dir, INDEX_DIR);
if (!existsSync(indexDir)) {
const typeIndex = buildTypeIndexFromDisk(nodesDir, hashSet);
const typeIndex = buildTypeIndexFromNodes(data);
if (typeIndex.size > 0) {
writeTypeIndex(indexDir, typeIndex);
}
@@ -162,8 +115,7 @@ function loadOrMigrateTypeIndex(
function loadOrMigrateMetaSet(
dir: string,
nodesDir: string,
hashSet: Set<Hash>,
data: Map<Hash, CasNode>,
): Set<Hash> {
const indexDir = join(dir, INDEX_DIR);
const metaPath = join(indexDir, META_FILE);
@@ -175,11 +127,10 @@ function loadOrMigrateMetaSet(
return new Set();
}
}
// Migration: scan nodes on disk for self-referencing nodes (type === hash)
// Migration: scan loaded nodes for self-referencing nodes (type === hash)
const metaSet = new Set<Hash>();
for (const hash of hashSet) {
const node = readNodeFromDisk(nodesDir, hash);
if (node && node.type === hash) {
for (const [hash, node] of data) {
if (node.type === hash) {
metaSet.add(hash);
}
}
@@ -230,6 +181,18 @@ function appendToTypeIndex(
typeIndex.set(type, list);
}
function hashesToEntries(
data: Map<Hash, CasNode>,
hashes: Iterable<Hash>,
): ListEntry[] {
const result: ListEntry[] = [];
for (const h of hashes) {
const node = data.get(h);
if (node) result.push(casListEntry(h, node.timestamp));
}
return result;
}
/**
* The CAS sub-store of an FS-backed `Store` also satisfies the legacy
* `BootstrapCapableStore` interface so `bootstrap()` can run against it.
@@ -240,50 +203,21 @@ export type FsCasStore = BootstrapCapableStore & {
};
export function createFsStore(dir: string): FsCasStore {
// Migrate any pre-#84 flat-layout .bin files at the root into nodes/.
migrateFlatLayoutToNodes(dir);
const nodesDir = join(dir, NODES_DIR);
// Lazy loading (#85): only scan filenames at startup — do NOT decode.
const hashSet = loadHashSet(nodesDir);
// In-memory cache of decoded nodes. Populated on first get() of each hash.
const cache = new Map<Hash, CasNode>();
const data = new Map<Hash, CasNode>();
loadDir(dir, data);
const indexDir = join(dir, INDEX_DIR);
const typeIndex = loadOrMigrateTypeIndex(dir, nodesDir, hashSet);
const metaSet = loadOrMigrateMetaSet(dir, nodesDir, hashSet);
/**
* Look up a node by hash, loading from disk on cache miss. Returns `null`
* if the hash is unknown or the file is corrupted.
*/
function loadNode(hash: Hash): CasNode | null {
const cached = cache.get(hash);
if (cached) return cached;
if (!hashSet.has(hash)) return null;
const node = readNodeFromDisk(nodesDir, hash);
if (node) cache.set(hash, node);
return node;
}
function hashesToEntries(hashes: Iterable<Hash>): ListEntry[] {
const result: ListEntry[] = [];
for (const h of hashes) {
const node = loadNode(h);
if (node) result.push(casListEntry(h, node.timestamp));
}
return result;
}
const typeIndex = loadOrMigrateTypeIndex(dir, data);
const metaSet = loadOrMigrateMetaSet(dir, data);
function putSelfReferencing(payload: unknown): Hash {
const hash = computeSelfHashSync(payload);
if (!hashSet.has(hash)) {
if (!data.has(hash)) {
const node: CasNode = { type: hash, payload, timestamp: Date.now() };
hashSet.add(hash);
cache.set(hash, node);
data.set(hash, node);
mkdirSync(nodesDir, { recursive: true });
const tmp = join(nodesDir, `${hash}.tmp`);
const dest = join(nodesDir, `${hash}.bin`);
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 }),
@@ -300,18 +234,17 @@ export function createFsStore(dir: string): FsCasStore {
put(typeHash: Hash, payload: unknown): Hash {
const hash = computeHashSync(typeHash, payload);
if (!hashSet.has(hash)) {
if (!data.has(hash)) {
const node: CasNode = {
type: typeHash,
payload,
timestamp: Date.now(),
};
hashSet.add(hash);
cache.set(hash, node);
data.set(hash, node);
mkdirSync(nodesDir, { recursive: true });
const tmp = join(nodesDir, `${hash}.tmp`);
const dest = join(nodesDir, `${hash}.bin`);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `${hash}.tmp`);
const dest = join(dir, `${hash}.bin`);
writeFileSync(
tmp,
cborEncode({ type: typeHash, payload, timestamp: node.timestamp }),
@@ -325,25 +258,25 @@ export function createFsStore(dir: string): FsCasStore {
},
get(hash: Hash): CasNode | null {
return loadNode(hash);
return data.get(hash) ?? null;
},
has(hash: Hash): boolean {
return hashSet.has(hash);
return data.has(hash);
},
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
const list = typeIndex.get(typeHash);
if (!list) return [];
return applyListOptions(hashesToEntries(list), options);
return applyListOptions(hashesToEntries(data, list), options);
},
listAll(): Hash[] {
return Array.from(hashSet);
return Array.from(data.keys());
},
listMeta(options?: ListOptions): ListEntry[] {
return applyListOptions(hashesToEntries(metaSet), options);
return applyListOptions(hashesToEntries(data, metaSet), options);
},
listSchemas(options?: ListOptions): ListEntry[] {
@@ -355,42 +288,38 @@ export function createFsStore(dir: string): FsCasStore {
for (const h of list) result.add(h);
}
}
return applyListOptions(hashesToEntries(result), options);
return applyListOptions(hashesToEntries(data, result), options);
},
delete(hash: Hash): boolean {
if (!hashSet.has(hash)) return false;
// Need the node's type to clean up the type index. Lazy-load if needed.
const node = loadNode(hash);
hashSet.delete(hash);
cache.delete(hash);
const node = data.get(hash);
if (!node) return false;
data.delete(hash);
// Delete file
try {
unlinkSync(join(nodesDir, `${hash}.bin`));
unlinkSync(join(dir, `${hash}.bin`));
} catch {
// ignore if file doesn't exist
}
// Remove from type index (only if we could decode the node)
if (node) {
const list = typeIndex.get(node.type);
if (list) {
const idx = list.indexOf(hash);
if (idx !== -1) {
list.splice(idx, 1);
}
if (list.length === 0) {
typeIndex.delete(node.type);
// Delete empty index file
try {
unlinkSync(join(indexDir, node.type));
} catch {
// ignore
}
} else {
// Rewrite index file
const body = `${list.join("\n")}\n`;
writeFileSync(join(indexDir, node.type), body, "utf8");
// Remove from type index
const list = typeIndex.get(node.type);
if (list) {
const idx = list.indexOf(hash);
if (idx !== -1) {
list.splice(idx, 1);
}
if (list.length === 0) {
typeIndex.delete(node.type);
// Delete empty index file
try {
unlinkSync(join(indexDir, node.type));
} catch {
// ignore
}
} else {
// Rewrite index file
const body = `${list.join("\n")}\n`;
writeFileSync(join(indexDir, node.type), body, "utf8");
}
}
// Remove from meta set if applicable
@@ -464,11 +393,10 @@ export async function prepareStore(dir: string): Promise<FsCasStore> {
*/
export async function openStore(dir: string): Promise<Store> {
const cas = await prepareStore(dir);
const sqlite = createSqliteVarStore(dir, cas);
const ocas: Store = {
cas,
var: sqlite.var,
tag: sqlite.tag,
var: createFsVarStoreFor(dir, cas),
tag: createFsTagStore(dir),
};
bootstrap(ocas);
return ocas;
+15 -6
View File
@@ -1,7 +1,7 @@
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { openStore } from "./store.js";
const T1 = "AAAAAAAAAAAAA";
@@ -16,7 +16,7 @@ describe("FsTagStore", () => {
rmSync(dir, { recursive: true, force: true });
});
test("B1. set tag with key/value round-trip + SQLite persisted", async () => {
test("B1. set tag with key/value round-trip + JSONL persisted", async () => {
const store = await openStore(dir);
const result = store.tag.tag(T1, [
{ op: "set", key: "env", value: "prod" },
@@ -26,8 +26,17 @@ describe("FsTagStore", () => {
expect(result[0]?.value).toBe("prod");
expect(store.tag.tags(T1)).toEqual(result);
const dbFile = join(dir, "_store.db");
expect(existsSync(dbFile)).toBe(true);
const jsonl = join(dir, "_tags.jsonl");
expect(existsSync(jsonl)).toBe(true);
const content = readFileSync(jsonl, "utf8");
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines).toHaveLength(1);
const parsed = JSON.parse(lines[0] as string) as {
key: string;
value: string;
};
expect(parsed.key).toBe("env");
expect(parsed.value).toBe("prod");
});
test("B2. label tag (no value) records value: null", async () => {
@@ -111,7 +120,7 @@ describe("FsTagStore", () => {
expect(tags.map((t) => t.value)).toEqual(["prod", "platform"]);
});
test("B11. SQLite replay fidelity (set/delete/untag mix)", async () => {
test("B11. JSONL replay fidelity (set/delete/untag mix)", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [{ op: "set", key: "a", value: "1" }]);
store.tag.tag(T1, [{ op: "set", key: "b", value: "2" }]);
+14 -5
View File
@@ -1,4 +1,5 @@
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
@@ -10,7 +11,6 @@ import {
TagLabelConflictError,
VariableNotFoundError,
} from "@ocas/core";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { openStore } from "./store.js";
const META_TYPE_KEY = Symbol.for("@ocas/core/bootstrap-store");
@@ -40,7 +40,7 @@ describe("FsVarStore", () => {
rmSync(dir, { recursive: true, force: true });
});
test("A1. set + get round-trip persists to SQLite", async () => {
test("A1. set + get round-trip persists to JSONL", async () => {
const { store, schema, put } = await setupStore(dir);
const h = put("hello");
const v = store.var.set("@app/x", h);
@@ -51,8 +51,17 @@ describe("FsVarStore", () => {
const got = store.var.get("@app/x", schema);
expect(got?.value).toBe(h);
const dbFile = join(dir, "_store.db");
expect(existsSync(dbFile)).toBe(true);
const jsonl = join(dir, "_vars.jsonl");
expect(existsSync(jsonl)).toBe(true);
const content = readFileSync(jsonl, "utf8");
expect(content.length).toBeGreaterThan(0);
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines.length).toBeGreaterThanOrEqual(1);
const matching = lines
.map((l) => JSON.parse(l) as { name?: string; value?: Hash })
.find((r) => r.name === "@app/x");
expect(matching).toBeDefined();
expect(matching?.value).toBe(h);
});
test("A2. name validation", async () => {
+418
View File
@@ -0,0 +1,418 @@
import {
appendFileSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type {
CasStore,
Hash,
ListEntry,
Tag,
TagStore,
Variable,
VarListOptions,
VarStore,
} from "@ocas/core";
import {
addNameIndex,
applyListOptions,
casListEntry,
checkTagLabelConflict,
cloneVarRecord,
extractSchema,
pushHistory,
removeNameIndex,
SchemaMismatchError,
VariableNotFoundError,
type VarRecord,
validateName,
varKey,
} from "@ocas/core";
const VARS_FILE = "_vars.jsonl";
const TAGS_FILE = "_tags.jsonl";
export function createFsVarStoreFor(dir: string, cas: CasStore): VarStore {
const records = new Map<string, VarRecord>();
const byName = new Map<string, Set<string>>();
const path = join(dir, VARS_FILE);
// Load existing records (last record per key wins)
try {
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const rec = JSON.parse(line) as VarRecord & { __op?: string };
if (rec.__op === "remove") {
const k = varKey(rec.name, rec.schema);
records.delete(k);
removeNameIndex(byName, rec.name, k);
} else {
const k = varKey(rec.name, rec.schema);
records.set(k, rec);
addNameIndex(byName, rec.name, k);
}
} catch {
// skip malformed
}
}
} catch {
// file may not exist
}
function persistFull(): void {
mkdirSync(dir, { recursive: true });
const lines: string[] = [];
for (const rec of records.values()) {
lines.push(JSON.stringify(rec));
}
writeFileSync(path, lines.length ? `${lines.join("\n")}\n` : "", "utf8");
}
function appendRecord(rec: VarRecord): void {
mkdirSync(dir, { recursive: true });
appendFileSync(path, `${JSON.stringify(rec)}\n`, "utf8");
}
function appendRemoval(name: string, schema: Hash): void {
mkdirSync(dir, { recursive: true });
appendFileSync(
path,
`${JSON.stringify({ __op: "remove", name, schema })}\n`,
"utf8",
);
}
return {
set(name, hash, options) {
validateName(name);
const schema = extractSchema(cas, hash);
const k = varKey(name, schema);
const existing = records.get(k);
const now = Date.now();
if (existing) {
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const changed = pushHistory(existing, hash, now);
if (changed) {
existing.value = hash;
existing.updated = now;
}
if (options !== undefined) {
existing.tags = { ...tags };
existing.labels = [...labels];
}
persistFull();
return cloneVarRecord(existing);
}
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
checkTagLabelConflict(tags, labels);
const rec: VarRecord = {
name,
schema,
value: hash,
created: now,
updated: now,
tags: { ...tags },
labels: [...labels],
history: [{ value: hash, position: 0, setAt: now }],
};
records.set(k, rec);
addNameIndex(byName, name, k);
appendRecord(rec);
return cloneVarRecord(rec);
},
get(name, schema) {
if (schema !== undefined) {
const rec = records.get(varKey(name, schema));
return rec ? cloneVarRecord(rec) : null;
}
const set = byName.get(name);
if (!set || set.size !== 1) return null;
const onlyKey = set.values().next().value;
if (onlyKey === undefined) return null;
const rec = records.get(onlyKey);
return rec ? cloneVarRecord(rec) : null;
},
remove(name, schema) {
if (schema !== undefined) {
const k = varKey(name, schema);
const rec = records.get(k);
if (!rec) return [];
records.delete(k);
removeNameIndex(byName, name, k);
appendRemoval(name, schema);
return [cloneVarRecord(rec)];
}
const set = byName.get(name);
if (!set) return [];
const removed: Variable[] = [];
for (const k of [...set]) {
const rec = records.get(k);
if (rec) {
removed.push(cloneVarRecord(rec));
records.delete(k);
appendRemoval(rec.name, rec.schema);
}
}
byName.delete(name);
return removed;
},
update(name, hash, options) {
validateName(name);
const newSchema = extractSchema(cas, hash);
const set = byName.get(name);
if (!set || set.size === 0)
throw new VariableNotFoundError(name, newSchema);
const k = varKey(name, newSchema);
const existing = records.get(k);
if (!existing) {
for (const ek of set) {
const erec = records.get(ek);
if (erec) throw new SchemaMismatchError(erec.schema, newSchema);
}
throw new VariableNotFoundError(name, newSchema);
}
const now = Date.now();
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const changed = pushHistory(existing, hash, now);
if (changed) {
existing.value = hash;
existing.updated = now;
}
if (options !== undefined) {
existing.tags = { ...tags };
existing.labels = [...labels];
}
persistFull();
return cloneVarRecord(existing);
},
list(options?: VarListOptions) {
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 ?? [];
const sort = options?.sort ?? "created";
const desc = options?.desc ?? false;
const limit = options?.limit;
const offset = options?.offset ?? 0;
if (limit !== undefined && limit <= 0) return [];
let results: VarRecord[] = [];
for (const rec of records.values()) {
if (exactName !== undefined && rec.name !== exactName) continue;
if (namePrefix !== undefined && !rec.name.startsWith(namePrefix))
continue;
if (schema !== undefined && rec.schema !== schema) continue;
let ok = true;
for (const [tk, tv] of Object.entries(filterTags)) {
if (rec.tags[tk] !== tv) {
ok = false;
break;
}
}
if (!ok) continue;
for (const lb of filterLabels) {
if (!rec.labels.includes(lb)) {
ok = false;
break;
}
}
if (!ok) continue;
results.push(rec);
}
results.sort((a, b) => {
const av = sort === "updated" ? a.updated : a.created;
const bv = sort === "updated" ? b.updated : b.created;
if (av !== bv) return desc ? bv - av : av - bv;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});
if (offset > 0) results = results.slice(offset);
if (limit !== undefined) results = results.slice(0, limit);
return results.map(cloneVarRecord);
},
history(name, schema) {
if (schema !== undefined) {
const rec = records.get(varKey(name, schema));
return rec ? rec.history.map((e) => ({ ...e })) : [];
}
const set = byName.get(name);
if (!set || set.size !== 1) return [];
const onlyKey = set.values().next().value;
if (onlyKey === undefined) return [];
const rec = records.get(onlyKey);
return rec ? rec.history.map((e) => ({ ...e })) : [];
},
close() {
// no-op (synchronous file ops)
},
};
}
type StoredTag = {
key: string;
value: string | null;
target: Hash;
created: number;
};
export function createFsTagStore(dir: string): TagStore {
const byTarget = new Map<Hash, Map<string, Tag>>();
const byKey = new Map<string, Set<Hash>>();
const path = join(dir, TAGS_FILE);
function addKeyIndex(k: string, target: Hash): void {
let set = byKey.get(k);
if (!set) {
set = new Set();
byKey.set(k, set);
}
set.add(target);
}
function removeKeyIndex(k: string, target: Hash): void {
const set = byKey.get(k);
if (!set) return;
const tmap = byTarget.get(target);
if (tmap?.has(k)) return;
set.delete(target);
if (set.size === 0) byKey.delete(k);
}
// Load
try {
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const ent = JSON.parse(line) as
| (StoredTag & { __op?: "set" | "untag" })
| { __op: "untag"; target: Hash; key: string };
if ((ent as { __op?: string }).__op === "untag") {
const e = ent as { target: Hash; key: string };
const tm = byTarget.get(e.target);
if (tm) {
tm.delete(e.key);
removeKeyIndex(e.key, e.target);
if (tm.size === 0) byTarget.delete(e.target);
}
} else {
const t = ent as StoredTag;
let tm = byTarget.get(t.target);
if (!tm) {
tm = new Map();
byTarget.set(t.target, tm);
}
tm.set(t.key, {
key: t.key,
value: t.value,
target: t.target,
created: t.created,
});
addKeyIndex(t.key, t.target);
}
} catch {
// skip
}
}
} catch {
// none
}
function append(line: object): void {
mkdirSync(dir, { recursive: true });
appendFileSync(path, `${JSON.stringify(line)}\n`, "utf8");
}
return {
tag(target, ops) {
let tm = byTarget.get(target);
if (!tm) {
tm = new Map();
byTarget.set(target, tm);
}
const now = Date.now();
for (const op of ops) {
if (op.op === "set") {
const existing = tm.get(op.key);
const tag: Tag = {
key: op.key,
value: op.value ?? null,
target,
created: existing?.created ?? now,
};
tm.set(op.key, tag);
addKeyIndex(op.key, target);
append(tag);
} else {
tm.delete(op.key);
removeKeyIndex(op.key, target);
append({ __op: "untag", target, key: op.key });
}
}
return [...tm.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
untag(target, keys) {
const tm = byTarget.get(target);
if (!tm) return;
for (const k of keys) {
tm.delete(k);
removeKeyIndex(k, target);
append({ __op: "untag", target, key: k });
}
if (tm.size === 0) byTarget.delete(target);
},
tags(target) {
const tm = byTarget.get(target);
if (!tm) return [];
return [...tm.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
listByTag(tag, options) {
let key = tag;
let value: string | null | undefined;
const eqIdx = tag.indexOf("=");
if (eqIdx >= 0) {
key = tag.slice(0, eqIdx);
value = tag.slice(eqIdx + 1);
}
const targets = byKey.get(key);
if (!targets) return [];
let entries: ListEntry[] = [];
for (const t of targets) {
const tm = byTarget.get(t);
if (!tm) continue;
const tagEntry = tm.get(key);
if (!tagEntry) continue;
if (value !== undefined && tagEntry.value !== value) continue;
entries.push(casListEntry(t, tagEntry.created));
}
entries = applyListOptions(entries, options);
return entries.map((e) => e.hash);
},
};
}
-2261
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
packages:
- "packages/*"
minimumReleaseAge: 0
allowBuilds:
esbuild: true
sharp: true
workerd: true
+1 -1
View File
@@ -1,5 +1,5 @@
name: "@ocas/workspace"
runtime: node
runtime: bun
packages:
- name: "@ocas/core"
path: packages/core
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"types": ["node"],
"types": [],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",