8 Commits

Author SHA1 Message Date
xiaoju 5d871a1357 Merge pull request 'chore: bump @ocas/core to 0.4.1' (#96) from chore/bump-core-0.4.1 into main
CI / check (push) Successful in 6m16s
2026-06-07 15:46:31 +00:00
xiaoju 01cf04fb3a docs: rewrite changelogs — per-package scope, add dates, fix 0.4.1 entry
CI / check (pull_request) Successful in 2m52s
2026-06-07 15:34:13 +00:00
xiaoju 1531255698 chore: bump @ocas/core to 0.4.1
CI / check (pull_request) Successful in 2m38s
2026-06-07 15:20:19 +00:00
xingyue 1f5eb7883c Merge pull request 'chore: remove legacy-packages/' (#95) from chore/remove-legacy-packages into main
CI / check (push) Successful in 2m51s
chore: remove legacy-packages/ (#95)
2026-06-07 15:13:29 +00:00
xiaomo db4072396b chore: remove legacy-packages/ and docs/ — outdated content
CI / check (pull_request) Successful in 5m47s
- legacy-packages/e2e-check.yaml: old CLI name, bun, wrong repo path
- docs/sync-readme.md: generic README convention, not needed
- Remove stale CLAUDE.md reference to docs/
2026-06-07 15:00:44 +00:00
xingyue 40dacee1be Merge pull request 'fix: gc must traverse oneOf and preserve template content' (#94) from fix/93-gc-collectrefs-oneof into main
CI / check (push) Successful in 2m38s
2026-06-07 13:36:08 +00:00
xiaoju 4659258693 fix: gc must traverse oneOf and preserve template content
CI / check (pull_request) Successful in 3m46s
collectRefs silently skipped oneOf even though it is in the meta-schema's
allowed keys. uwf step nodes use the standard JSON-Schema idiom
oneOf: [{type:"null"}, {type:"string", format:"ocas_ref"}] for nullable
prev/detail/start refs, so walk() never reached the chain and gc swept
the intermediate steps as false orphans. Mirror the anyOf branch in
collectRefs so every oneOf variant contributes refs.

Also align gc with closure.ts Phase 3: walk @ocas/template/text/<schema>
content for every reachable schema so rendered template nodes survive
when their schema is reachable, and are still collected when the schema
itself is unreachable.

Fixes #93
2026-06-07 13:06:59 +00:00
xiaomo f5eef854a3 Merge pull request 'chore: release @ocas/core@0.4.0, @ocas/fs@0.4.0, @ocas/cli@0.4.0' (#92) from release/v0.4.0 into main
CI / check (push) Successful in 4m5s
2026-06-07 06:10:55 +00:00
12 changed files with 535 additions and 593 deletions
-4
View File
@@ -86,10 +86,6 @@ 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
-68
View File
@@ -1,68 +0,0 @@
# 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** — pnpm install / build / check / test
8. **Publishing**`proman bump` + `proman publish`
## 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** — pnpm 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
- pnpm 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
-414
View File
@@ -1,414 +0,0 @@
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:
new: { role: "preparer", prompt: "Set up Docker environment for E2E testing. Repo at {{{repoPath}}}." }
resume: { role: "preparer", prompt: "Review previous E2E run and continue 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}}}" }
+32 -44
View File
@@ -2,64 +2,52 @@
## 0.4.0 — 2026-06-07
- Rename `ocas prompt setup` to `ocas prompt bootstrap` with programmatic generation (dynamic CLI_VERSION injection). Add `ocas prompt list` subcommand.
- Add CAS closure export/import (`ocas export` / `ocas import`):
- **`@ocas/core`**: 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`). Added `@ocas/output/export` and `@ocas/output/import` builtin output schemas.
- **`@ocas/cli`**: New `ocas export <root> [<root> ...] -o <bundle.tar>` and `ocas import <bundle.tar> [--scope @new]` commands. New global `--store <bundle.tar>` flag opens a bundle as a read-only store for inspection commands (`get`, `walk`, `refs`, `var list`, …). Write commands reject `--store` with a clear error.
- 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
## 0.3.1 — 2026-06-04
### Patch Changes
- Fix prompt docs: `bun``pnpm` install instructions, remove stale `--var-db` flag.
- Fix prompt docs: `bun``pnpm` install instructions, remove stale `--var-db` flag from usage docs.
## 0.3.0 — 2026-06-03
## 0.2.0
- 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
### 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** (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
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead.
### New Features
- `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
- 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.
## 0.1.2
### Patch Changes
## 0.1.2 — 2026-06-02
- Fix render output missing trailing newline.
- 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.
- Add agent skill setup hint with version to help output.
- Remove postinstall script.
- Updated dependencies:
- @ocas/core@0.1.2
- @ocas/fs@0.1.2
## 0.1.1 — 2026-06-02
## 0.1.1
- Add `ocas prompt usage` and `ocas prompt setup` commands.
- Add `--version` flag.
### Patch Changes
## 0.1.0 — 2026-06-01
- 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.
Initial release. CLI tool for OCAS with put, get, list, render, verify, gc, var, and template commands.
+97
View File
@@ -123,3 +123,100 @@ 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 });
}
});
});
+34 -29
View File
@@ -1,47 +1,52 @@
# @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
- Add CAS closure export/import (`ocas export` / `ocas import`):
- **`@ocas/core`**: 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`). Added `@ocas/output/export` and `@ocas/output/import` builtin output schemas.
- **`@ocas/cli`**: New `ocas export <root> [<root> ...] -o <bundle.tar>` and `ocas import <bundle.tar> [--scope @new]` commands. New global `--store <bundle.tar>` flag opens a bundle as a read-only store for inspection commands (`get`, `walk`, `refs`, `var list`, …). Write commands reject `--store` with a clear error.
- 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.2.0
## 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
### 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** (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
- `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.
### New Features
- `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
- `CasStore`, `VarStore`, `TagStore` sub-store types.
- `validation.ts` — shared `validateName()` exported from core.
## 0.1.2
### Patch Changes
## 0.1.2 — 2026-06-02
- Fix TypeScript LSP errors: tsconfig `noEmit`, `exactOptionalPropertyTypes` conditional spread, `schemaHash` scope.
## 0.1.1
## 0.1.1 — 2026-06-02
### Patch Changes
- Internal improvements.
- Internal improvements for v0.1.1 release.
## 0.1.0 — 2026-06-01
## 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.
Initial release. Content-addressable store engine with JSON Schema typed nodes, XXH64 hashing, variable store, render with resolution decay, and LiquidJS template support.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ocas/core",
"version": "0.4.0",
"version": "0.4.1",
"description": "Core CAS engine — hashing, schema, store, verify, bootstrap",
"keywords": [
"cas",
+216
View File
@@ -119,3 +119,219 @@ 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);
});
});
+40 -2
View File
@@ -8,10 +8,17 @@ 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)
* - 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.
* - 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
*/
@@ -21,9 +28,13 @@ export function gc(store: Store): GcStats {
const variables = store.var.list();
const scanned = variables.length;
// Collect unique root hashes from all variables
// 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.
const roots = new Set<Hash>();
for (const variable of variables) {
if (variable.name.startsWith(TEMPLATE_VAR_PREFIX)) continue;
roots.add(variable.value);
}
@@ -63,6 +74,33 @@ 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();
+79
View File
@@ -743,3 +743,82 @@ 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,6 +311,17 @@ 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[]) {
+25 -31
View File
@@ -2,50 +2,44 @@
## 0.4.0 — 2026-06-07
- `FsStore` now uses lazy loading: at startup it scans only filenames in the `nodes/` subdirectory (no CBOR decoding) and reads each node from disk on first `get()`. This makes startup O(filenames) instead of O(decoded-bytes), keeps memory usage bounded by what's actually accessed, and avoids paying the full-load cost for stores with many nodes. Behaviour is unchanged: `has()`, `listAll()`, `listByType()`, `listMeta()`, and `listSchemas()` return the same results as before. Index/meta migration paths still work — they perform a one-time scan + decode when `_index/` is missing.
- Move CAS node files from the store root into a `nodes/` subdirectory. Pre-existing flat-layout stores are auto-migrated on first open: any `<HASH>.bin` files in the store root are renamed into `nodes/`. Metadata (`_index/`, `_store.db`, `_meta`) remain at the store root unchanged.
- 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, no more NODE_MODULE_VERSION mismatch across Node upgrades.
- Migrate from `better-sqlite3` to built-in `node:sqlite` — zero native addon dependencies.
## 0.2.0
## 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
### 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** (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
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`.
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally.
### New Features
- `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
- SQLite-backed `VarStore` and `TagStore` implementations.
- `TagStore` — first-class tags on any CAS node.
- `var-store-helpers.ts` — shared validation/history logic.
## 0.1.2
## 0.1.2 — 2026-06-02
### Patch Changes
- Updated dependencies.
- Updated dependencies:
- @ocas/core@0.1.2
## 0.1.1 — 2026-06-02
## 0.1.1
- Updated dependencies.
### Patch Changes
## 0.1.0 — 2026-06-01
- 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.
Initial release. Filesystem-backed CAS store with SQLite indexing and auto-bootstrap.