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
OCAS
Object Content Addressable Store — self-describing CAS with JSON Schema typed nodes.
Every node has a typed payload: its type field is the hash of a JSON Schema that describes the payload shape. Hashes are 13-character Crockford Base32 strings derived from XXH64 over deterministic CBOR encoding. Payloads can reference other nodes via format: "cas_ref" fields, forming a traversable DAG.
Install
pnpm 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
# Register a schema (schemas are just nodes typed by the meta-schema)
echo '{
"type": "object",
"properties": { "title": { "type": "string" }, "done": { "type": "boolean" } },
"required": ["title", "done"],
"additionalProperties": false
}' | ocas put @ocas/schema -p
# → { "type": "...", "value": "1ABC2DEF34567" }
# Give it a friendly name
ocas var set @todo/schema 1ABC2DEF34567
# Store a todo item
echo '{ "title": "Buy milk", "done": false }' | ocas put @todo/schema -p
# → { "type": "...", "value": "9XYZ8WVU76543" }
# Retrieve it
ocas get 9XYZ8WVU76543
# Verify integrity + schema validation
ocas verify 9XYZ8WVU76543
Envelope Format
Every command outputs a { type, value } JSON envelope. type is the hash of the result schema, value is the payload. This makes output self-describing and composable:
# Pipe any command's output into render
ocas put @ocas/schema schema.json | ocas render -p
# Or use the shorthand -r flag
ocas get 9XYZ8WVU76543 -r
# Extract values with jq
ocas list --type @ocas/schema | jq -r '.value[].hash'
render is the only command that emits raw text instead of an envelope.
Commands
Store & Retrieve
ocas put <type> <file> # store a node, returns its hash
ocas put <type> -p # read payload from stdin
ocas get <hash> # retrieve a node
ocas has <hash> # check if a node exists
ocas hash <type> <file> # compute hash without storing
ocas verify <hash> # integrity check + schema validation
Graph Traversal
ocas refs <hash> # list direct cas_ref edges
ocas walk <hash> # recursive DAG traversal
ocas walk <hash> --format tree # tree-view output
Listing & Querying
ocas list --type <hash|name> # list nodes by type
ocas list-schema # list all schemas
ocas list-meta # list meta-schema hashes
All list commands support sorting and pagination:
ocas list --type todo/schema --sort updated --desc --limit 20
ocas list --type todo/schema --offset 20 --limit 20 # page 2
ocas list-schema --sort created --limit 50
Variables
Variables are mutable pointers to immutable data — like git branches pointing to commits.
Names must follow @scope/name format (e.g. @myapp/config). The @ocas/* scope is reserved.
ocas var set @myapp/config <hash> # bind a name to a hash
ocas var get @myapp/config # look up current binding
ocas var delete @myapp/config # remove binding
ocas var list [prefix] # list variables (prefix filter)
ocas var list @myapp/ --tag env:prod # filter by scope prefix and tag
ocas var history @myapp/config # show last 10 values (LRU)
Tags & labels — attach metadata to variables:
ocas var set @myapp/config <hash> --tag env:prod --tag pinned
ocas var tag @myapp/config --schema <hash> status:active # add tag
ocas var tag @myapp/config --schema <hash> :status # remove tag
Any command that takes a hash also accepts a variable name:
ocas get @myapp/config # resolves to the bound hash
ocas put @ocas/schema s.json # @ocas/schema is a builtin variable
Templates & Rendering
Bind a LiquidJS template to a schema, then render nodes of that type:
# Set a template
ocas template set <schema-hash> --inline "Todo: {{ payload.title }} [{{ payload.done }}]"
# Render a node (uses the template for its type, falls back to YAML)
ocas render <hash>
# Render from a pipe (any envelope)
ocas gc | ocas render -p
# Inline render shorthand
ocas get <hash> -r
Render options for recursive reference expansion:
ocas render <hash> --resolution 3 # max recursion depth
ocas render <hash> --decay 0.5 # depth decay factor
ocas render <hash> --epsilon 0.01 # cutoff threshold
Garbage Collection
ocas gc # collect unreachable nodes
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:
# 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 |
--sort created|updated |
Sort key for list commands (default: created) |
--limit <n> |
Max results (default: 100) |
--offset <n> |
Skip first N results (default: 0) |
--desc |
Sort descending |
Architecture
┌───────────┐
│ @ocas/cli │ CLI interface
└─────┬─────┘
│
┌───────────┐
│ @ocas/fs │ Filesystem store (CBOR + SQLite)
└─────┬─────┘
│
┌────────────┐
│ @ocas/core │ Hashing, schemas, validation, bootstrap
└────────────┘
Using as a Library
pnpm add @ocas/core # in-memory store
pnpm add @ocas/core @ocas/fs # + filesystem persistence
import { bootstrap, createMemoryStore, putSchema } from "@ocas/core";
const store = createMemoryStore();
await bootstrap(store);
const typeHash = await putSchema(store, {
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
additionalProperties: false,
});
const hash = await store.put(typeHash, { message: "hello" });
const node = store.get(hash);
For filesystem persistence, use @ocas/fs:
import { openStore } from "@ocas/fs";
const store = await openStore("/path/to/store");
See individual package READMEs for full API docs:
@ocas/core ·
@ocas/fs ·
@ocas/cli
Development
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