Compare commits
55 Commits
547c026095
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d19707e7b | |||
| 20922d92c2 | |||
| b22da63bf8 | |||
| 45f0d83af3 | |||
| 56f003077a | |||
| 52ebd9ed8e | |||
| ec6db5f494 | |||
| 5d54c0988c | |||
| fbcd1b5b0e | |||
| 191926428a | |||
| 077537ea5b | |||
| 84386f8a63 | |||
| eebdeb23da | |||
| 350ee3d7b5 | |||
| 02a364eb0b | |||
| 8176a228b2 | |||
| 2952b953b3 | |||
| 1554fbc719 | |||
| 3d903eaff8 | |||
| d8f2abbe12 | |||
| 0919f71f7e | |||
| 944f6c3254 | |||
| 6bc767d37e | |||
| efb0a0e721 | |||
| 5f544c019f | |||
| ab1e6df774 | |||
| b43bbef80f | |||
| b687ff82b3 | |||
| 04433e81ab | |||
| c9829e70ee | |||
| b19f38c41a | |||
| 52072a0065 | |||
| ec9e04c5c6 | |||
| 558a70fc04 | |||
| 656c780270 | |||
| 307fa032ad | |||
| 46d7991002 | |||
| 60d7897206 | |||
| 54188a1015 | |||
| adc984a653 | |||
| 6578311a93 | |||
| 68779f76c6 | |||
| 0b7883efc2 | |||
| 3859187989 | |||
| b99d74093c | |||
| bfaa2722fc | |||
| 48743cbf4f | |||
| 8504abbb5a | |||
| 5463b44554 | |||
| 22b5474a77 | |||
| 190ae672a7 | |||
| ed19466a3b | |||
| 92a024fc1c | |||
| 93947c35d0 | |||
| d83173107a |
+5
-2
@@ -46,7 +46,6 @@ ocas var delete <name> [--schema <hash>]
|
||||
ocas var list [prefix] [--schema <hash>] [--tag ...]
|
||||
ocas var tag <name> --schema <hash> <operations...>
|
||||
ocas var history <name> [--schema <hash>]
|
||||
ocas var rollback <name> <position> [--schema <hash>]
|
||||
```
|
||||
|
||||
### [[Render System|Template & Render]]
|
||||
@@ -73,6 +72,10 @@ ocas render --pipe/-p [options]
|
||||
| `--render`, `-r` | Render output inline (equivalent to piping to `ocas render -p`) |
|
||||
| `--inline <text>` | Inline text content for `template set` |
|
||||
| `--format tree` | Tree display for `walk` |
|
||||
| `--sort created\|updated` | Sort key for list commands (default: `created`; for CAS nodes both are equivalent) |
|
||||
| `--limit <n>` | Max results to return (default: 100) |
|
||||
| `--offset <n>` | Skip first N results (default: 0) |
|
||||
| `--desc` | Sort descending (default: ascending) |
|
||||
|
||||
## Variable Names
|
||||
|
||||
@@ -84,4 +87,4 @@ ocas put @ocas/schema schema.json # auto-routes to putSchema()
|
||||
ocas list --type @ocas/schema # list all schemas
|
||||
```
|
||||
|
||||
User-defined variable names work the same way — once `ocas var set myapp/config <hash>` is registered, `ocas get myapp/config` resolves to that hash.
|
||||
All variable names must follow `@scope/name` format. User-defined names work just like builtins — once `ocas var set @myapp/config <hash>` is registered, `ocas get @myapp/config` resolves to that hash.
|
||||
|
||||
+15
-4
@@ -16,14 +16,25 @@ type Store = {
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash>;
|
||||
get(hash: Hash): CasNode | null;
|
||||
has(hash: Hash): boolean;
|
||||
delete(hash: Hash): void;
|
||||
listAll(): Hash[];
|
||||
listByType(typeHash: Hash): Hash[];
|
||||
listMeta(): Hash[];
|
||||
listSchemas(): Hash[];
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[];
|
||||
listMeta(options?: ListOptions): ListEntry[];
|
||||
listSchemas(options?: ListOptions): ListEntry[];
|
||||
};
|
||||
```
|
||||
|
||||
### ListOptions & ListEntry
|
||||
|
||||
List methods accept optional `ListOptions` for sorting and pagination:
|
||||
|
||||
```typescript
|
||||
type ListSort = "created" | "updated";
|
||||
type ListOptions = { sort?: ListSort; desc?: boolean; limit?: number; offset?: number };
|
||||
type ListEntry = { hash: Hash; created: number; updated: number };
|
||||
```
|
||||
|
||||
When `limit` is `undefined`, all results are returned (no cap). The [[CLI]] defaults `limit` to 100.
|
||||
|
||||
`put()` computes the [[Content Addressing|hash]] from `{ type, payload }`, validates the payload against its [[Schema]], and stores the [[Content Addressing|node]]. If a node with the same hash already exists, it's a no-op — content addressing gives deduplication for free.
|
||||
|
||||
## Implementations
|
||||
|
||||
+15
-2
@@ -27,6 +27,17 @@ type Variable = {
|
||||
|
||||
The primary key is `(name, schema)` — the same name can point to nodes of different types. When using `var set`, the schema is automatically inferred from the node the hash points to, so you don't need to specify it explicitly.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
All variable names must follow `@scope/name` format:
|
||||
|
||||
- **Pattern**: `@[a-zA-Z][a-zA-Z0-9]*/` followed by one or more segments of `[a-zA-Z0-9._-]+`
|
||||
- **`@ocas/*`** is reserved for internal use (builtin schemas, templates)
|
||||
- **Examples**: `@myapp/config`, `@todo/schema`, `@data/users.prod`
|
||||
- **Rejected**: `config` (no scope), `foo/bar` (no `@`), `@/foo` (empty scope), `@123/foo` (digit scope)
|
||||
|
||||
The `@scope` prefix ensures variable names are visually distinct from 13-character hashes.
|
||||
|
||||
## Operations
|
||||
|
||||
```bash
|
||||
@@ -62,13 +73,15 @@ Every variable tracks its last `MAX_HISTORY` (default 10) values with LRU rotati
|
||||
|
||||
- **set()** appends to history: if the new value already exists in history, it rotates to position 0; otherwise it's inserted at 0 and the oldest entry beyond MAX_HISTORY is evicted.
|
||||
- **Idempotent**: setting the same value as the current (position 0) is a no-op — important for [[Bootstrap]] which calls set() repeatedly.
|
||||
- **rollback(name, schema, position)** promotes a historical value back to current.
|
||||
|
||||
```bash
|
||||
ocas var history <name> [--schema <hash-or-name>] # show history, [0] = current
|
||||
ocas var rollback <name> <position> [--schema <hash-or-name>] # restore a previous value
|
||||
```
|
||||
|
||||
## Sorting & Pagination
|
||||
|
||||
`var list` and `var history` support the common list flags: `--sort created|updated`, `--limit`, `--offset`, `--desc`. The CLI defaults `--limit` to 100; the core layer returns all results when `limit` is omitted.
|
||||
|
||||
## Role in Garbage Collection
|
||||
|
||||
Variables are the **roots** of [[Garbage Collection]]. Any node reachable from a variable's value hash (via [[Schema|ocas_ref]] edges) is kept alive; unreachable nodes are swept.
|
||||
|
||||
@@ -60,12 +60,19 @@ bun run format # Biome format (auto-fix)
|
||||
- `CasNode` — content-addressed node with schema
|
||||
- `Store` — abstract storage interface (get/put)
|
||||
- `VariableStore` — SQLite-backed mutable bindings (name → hash)
|
||||
- `ListOptions` — sorting/pagination options (`sort`, `desc`, `limit`, `offset`)
|
||||
- `ListEntry` — list result entry (`hash`, `created`, `updated`)
|
||||
|
||||
### List Utilities
|
||||
|
||||
`packages/core/src/list-utils.ts` provides `applyListOptions()` — in-memory sort/paginate for `ListEntry[]` arrays. Used by `MemoryStore`; `FsStore` pushes sort/limit to SQLite. Core layer treats `limit: undefined` as "no limit"; the CLI defaults to 100 in `parseListOptions()`.
|
||||
|
||||
### Architecture Notes
|
||||
|
||||
- **No "alias" concept** — every name resolution flows through the `VariableStore`. Builtin schemas (`@ocas/schema`, `@ocas/string`, `@ocas/output/*`, …) are registered as variables during `bootstrap(store, varStore)`, alongside user-defined variables created via `ocas var set`.
|
||||
- **`bootstrap(store, varStore?)`** writes builtin name → hash bindings into the varStore when one is provided; called automatically by `openStore()` and `openStoreAndVarStore()`.
|
||||
- **`resolveHash(input, varStore)`** is the unified hash/name resolver in the CLI. If `input` matches the 13-char hash format it is returned as-is; otherwise the varStore 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.
|
||||
- **`openStoreAndVarStore()`** in the CLI opens both stores and bootstraps once; prefer it over separate `openStore()` + `createVariableStore()` to avoid double bootstrap.
|
||||
|
||||
### Internal Dependencies
|
||||
@@ -91,42 +98,65 @@ This is resolved to real version numbers only during publishing (see below).
|
||||
|
||||
## Release Process
|
||||
|
||||
Releases use a **release branch** workflow. `main` always keeps `workspace:*` for
|
||||
internal dependencies; version numbers are only fixed on the release branch.
|
||||
Releases use a **release branch** workflow with three phases: prepare → candidate → finalize.
|
||||
|
||||
### Prepare
|
||||
|
||||
```bash
|
||||
./scripts/prepare-release.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
1. Checks you're on `main` with a clean tree and pending changesets
|
||||
2. Creates `release/<version>` branch
|
||||
3. Runs `changeset version` to fix versions and generate CHANGELOGs
|
||||
4. Runs full validation (install, build, lint, test)
|
||||
5. Commits the version bump
|
||||
|
||||
After preparation, review changes and fix any issues on the release branch.
|
||||
|
||||
### Publish
|
||||
|
||||
```bash
|
||||
./scripts/publish.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
1. Validates you're on a `release/*` branch with no pending changesets
|
||||
2. Runs final build + test
|
||||
3. Publishes packages in order: `@ocas/core` → `@ocas/fs` → `@ocas/cli`
|
||||
4. Tags, pushes, merges back to `main`, cleans up the release branch
|
||||
`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
|
||||
|
||||
Before releasing, add changesets for your changes:
|
||||
Add changesets alongside feature PRs on `main`:
|
||||
|
||||
```bash
|
||||
bunx changeset # interactive — pick packages + bump type + summary
|
||||
```markdown
|
||||
<!-- .changeset/my-change.md -->
|
||||
---
|
||||
"@ocas/cli": patch
|
||||
---
|
||||
|
||||
Description of the change
|
||||
```
|
||||
|
||||
Changesets live in `.changeset/` as markdown files until consumed by `prepare-release.sh`.
|
||||
Changesets live in `.changeset/` as markdown files. Bump types: `patch` / `minor` / `major`.
|
||||
One changeset can cover multiple packages.
|
||||
|
||||
### Phase 1: Prepare (cut release branch)
|
||||
|
||||
- **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
|
||||
|
||||
### 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:*`** 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
|
||||
|
||||
@@ -2,62 +2,200 @@
|
||||
|
||||
**Object Content Addressable Store** — self-describing CAS with JSON Schema typed nodes.
|
||||
|
||||
## Overview
|
||||
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.
|
||||
|
||||
OCAS is a monorepo for storing and validating JSON data in a content-addressable store (CAS). Each node has a typed payload: its `type` field is the hash of a JSON Schema node that describes the payload shape. Hashes are 13-character Crockford Base32 strings derived from XXH64 over deterministic CBOR encoding.
|
||||
## Install
|
||||
|
||||
A bootstrap meta-schema is stored as a self-referencing seed node (`type === hash`). All other schemas are registered as nodes typed by that meta-schema. Payloads can reference other nodes via `format: "cas_ref"` fields; the library provides traversal, reference extraction, and integrity verification.
|
||||
```bash
|
||||
bun add -g @ocas/cli
|
||||
```
|
||||
|
||||
Use the in-memory store for tests and embedded apps, the filesystem store for persistence, and the CLI for local store management.
|
||||
The store is auto-created and bootstrapped on first use — no `init` command needed.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
ocas refs <hash> # list direct cas_ref edges
|
||||
ocas walk <hash> # recursive DAG traversal
|
||||
ocas walk <hash> --format tree # tree-view output
|
||||
```
|
||||
|
||||
### Listing & Querying
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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](https://liquidjs.com/) template to a schema, then render nodes of that type:
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
|
||||
| `--var-db <path>` | Variable database path |
|
||||
| `--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 │
|
||||
│ @ocas/cli │ CLI interface
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ @ocas/fs │
|
||||
│ @ocas/fs │ Filesystem store (CBOR + SQLite)
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌────────────┐
|
||||
│ @ocas/core │
|
||||
│ @ocas/core │ Hashing, schemas, validation, bootstrap
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
| Layer | Package | Role |
|
||||
|-------|---------|------|
|
||||
| Core | `@ocas/core` | Hashing, schemas, stores, verify, bootstrap |
|
||||
| Storage | `@ocas/fs` | Filesystem-backed `Store` |
|
||||
| CLI | `@ocas/cli` | `ocas` command-line tool |
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description | Type |
|
||||
|---------|-------------|------|
|
||||
| [`@ocas/core`](packages/core/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
|
||||
| [`@ocas/fs`](packages/fs/README.md) | Filesystem-backed CAS store | lib |
|
||||
| [`@ocas/cli`](packages/cli/README.md) | CLI tool (`ocas` binary) | cli |
|
||||
|
||||
## Quick Start
|
||||
## Using as a Library
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd ocas
|
||||
bun install --no-cache
|
||||
bun run build
|
||||
bun add @ocas/core # in-memory store
|
||||
bun add @ocas/core @ocas/fs # + filesystem persistence
|
||||
```
|
||||
|
||||
```typescript
|
||||
import {
|
||||
bootstrap,
|
||||
createMemoryStore,
|
||||
putSchema,
|
||||
validate,
|
||||
} from "@ocas/core";
|
||||
import { bootstrap, createMemoryStore, putSchema } from "@ocas/core";
|
||||
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
@@ -71,110 +209,32 @@ const typeHash = await putSchema(store, {
|
||||
|
||||
const hash = await store.put(typeHash, { message: "hello" });
|
||||
const node = store.get(hash);
|
||||
console.log(validate(store, node!)); // true
|
||||
```
|
||||
|
||||
For a persistent store:
|
||||
For filesystem persistence, use `@ocas/fs`:
|
||||
|
||||
```typescript
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { openStoreAndVarStore } from "@ocas/fs";
|
||||
|
||||
const store = createFsStore("/path/to/store");
|
||||
await bootstrap(store);
|
||||
const { store, varStore } = await openStoreAndVarStore("/path/to/store");
|
||||
```
|
||||
|
||||
Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli/README.md`](packages/cli/README.md)).
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Binary: `ocas` (from `@ocas/cli`). Default store: `~/.ocas`.
|
||||
|
||||
The store is auto-created and bootstrapped on first use — there is no `init`/`bootstrap`
|
||||
command, and schemas are ordinary `@schema`-typed nodes (`ocas put @schema file.json`),
|
||||
so there is no `schema` subcommand.
|
||||
|
||||
### Envelope format
|
||||
|
||||
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
|
||||
of the command's `@output/*` result schema and `value` is the command payload. This makes
|
||||
output self-describing and pipeable: feed any envelope into `render -p` to render its
|
||||
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
|
||||
raw (non-envelope) text.
|
||||
|
||||
```jsonc
|
||||
// ocas has <hash>
|
||||
{ "type": "AYHQD2YA9G667", "value": true }
|
||||
```
|
||||
|
||||
```
|
||||
Usage: ocas [--home <path>] [--json] <command> [args]
|
||||
|
||||
Commands (all emit a { type, value } envelope unless noted):
|
||||
put <type-hash> <file.json> Store node (value = hash) (@output/put)
|
||||
get <hash> Node payload + metadata (@output/get)
|
||||
has <hash> Existence boolean (@output/has)
|
||||
verify <hash> ok / corrupted / invalid (@output/verify)
|
||||
refs <hash> Direct cas_ref edges (@output/refs)
|
||||
walk <hash> [--format tree] Recursive traversal (@output/walk)
|
||||
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
|
||||
render <hash> [options] Render node as text (raw output)
|
||||
render --pipe/-p [options] Render a piped envelope (raw output)
|
||||
list --type <hash-or-name> Hashes for a type (value = list) (@output/list)
|
||||
list-meta Meta-schema hashes (@output/list-meta)
|
||||
list-schema All schema hashes (@output/list-schema)
|
||||
var set|get|delete|tag|list ... Variable CRUD (@output/var-*)
|
||||
template set|get|list|delete ... Output-template CRUD (@output/template-*)
|
||||
gc Garbage collection (@output/gc)
|
||||
|
||||
Flags:
|
||||
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
|
||||
--json Compact JSON output
|
||||
--pipe, -p Read a { type, value } envelope from stdin for render
|
||||
```
|
||||
|
||||
### Variable names
|
||||
|
||||
Any command that takes a hash also accepts a variable name. Builtin schemas
|
||||
(`@ocas/schema`, `@ocas/string`, `@ocas/object`, `@ocas/output/*`, …) are
|
||||
registered in the variable store during bootstrap; user variables created via
|
||||
`ocas var set <name> <hash>` resolve the same way. There is no separate alias
|
||||
concept — every name lookup queries the variable store.
|
||||
|
||||
### Pipe examples
|
||||
|
||||
```bash
|
||||
# Store a node, then render the stored content (the put envelope's hash is
|
||||
# a cas_ref, so render -p dereferences and renders it):
|
||||
ocas put @schema ./schemas/item.json | ocas render -p
|
||||
|
||||
# Render garbage-collection stats:
|
||||
ocas gc | ocas render -p
|
||||
|
||||
# List every schema, then consume the envelope's value array with jq:
|
||||
ocas list --type @schema | jq -r '.value[]'
|
||||
```
|
||||
See individual package READMEs for full API docs:
|
||||
[`@ocas/core`](packages/core/README.md) ·
|
||||
[`@ocas/fs`](packages/fs/README.md) ·
|
||||
[`@ocas/cli`](packages/cli/README.md)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun install --no-cache # install workspace dependencies
|
||||
bun run build # tsc --build (libs)
|
||||
bun run check # biome check
|
||||
bun run format # biome format --write
|
||||
bun test # run all package tests
|
||||
git clone <repo-url> && cd ocas
|
||||
bun install --no-cache
|
||||
bun run build # tsc --build
|
||||
bun test # run all tests
|
||||
bun run check # biome lint
|
||||
bun run format # biome format
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
Releases use [Changesets](https://github.com/changesets/changesets). From the repo root:
|
||||
|
||||
```bash
|
||||
bun run release # changeset version → build → publish to npm (@ocas/*)
|
||||
```
|
||||
|
||||
Individual packages block `prepublishOnly` and expect releases via the workspace `release` script.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -15,18 +15,18 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@ocas/cli",
|
||||
"version": "0.6.0",
|
||||
"version": "0.1.1",
|
||||
"bin": {
|
||||
"ocas": "src/index.ts",
|
||||
},
|
||||
"dependencies": {
|
||||
"@ocas/core": "^0.6.0",
|
||||
"@ocas/fs": "^0.6.0",
|
||||
"@ocas/core": "0.1.1",
|
||||
"@ocas/fs": "0.1.1",
|
||||
},
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@ocas/core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
@@ -36,9 +36,9 @@
|
||||
},
|
||||
"packages/fs": {
|
||||
"name": "@ocas/fs",
|
||||
"version": "0.6.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@ocas/core": "^0.6.0",
|
||||
"@ocas/core": "0.1.1",
|
||||
"cborg": "^4.2.3",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,5 +18,13 @@
|
||||
"check": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"release": "changeset version && bun run build && changeset publish"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/ocas.git"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/ocas",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-52
@@ -1,63 +1,28 @@
|
||||
# @uncaged/cli-json-cas
|
||||
# @ocas/cli
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
|
||||
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
|
||||
- `openStore()` is now async and auto-bootstraps
|
||||
|
||||
### New Features
|
||||
|
||||
- 18 `@output/*` schemas registered at bootstrap
|
||||
- `list --type <hash-or-alias>` command (replaces `schema list`)
|
||||
- `verify` now checks both hash integrity and schema validation
|
||||
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
|
||||
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
|
||||
- Default LiquidJS templates for all output schemas
|
||||
- Pipe composition: any command output can be piped to `render -p`
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.6.0
|
||||
- @uncaged/json-cas-fs@0.6.0
|
||||
- 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.
|
||||
|
||||
## 0.5.3
|
||||
- Updated dependencies:
|
||||
- @ocas/core@0.1.2
|
||||
- @ocas/fs@0.1.2
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.5.3
|
||||
- @uncaged/json-cas-fs@0.5.3
|
||||
- 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.
|
||||
|
||||
## 0.3.0
|
||||
- Updated dependencies:
|
||||
- @ocas/core@0.1.1
|
||||
- @ocas/fs@0.1.1
|
||||
|
||||
### Patch Changes
|
||||
## 0.1.0
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.3.0
|
||||
- @uncaged/json-cas-fs@0.3.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.2.0
|
||||
- @uncaged/json-cas-fs@0.2.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: replace workspace:^ with actual version numbers in published dependencies
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.1.3
|
||||
- @uncaged/json-cas-fs@0.1.3
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ocas/cli",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ocas": "src/index.ts"
|
||||
@@ -10,7 +10,16 @@
|
||||
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ocas/core": "workspace:*",
|
||||
"@ocas/fs": "workspace:*"
|
||||
"@ocas/core": "0.1.2",
|
||||
"@ocas/fs": "0.1.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/ocas.git",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/cli",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
}
|
||||
}
|
||||
|
||||
+161
-110
@@ -3,7 +3,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { Hash, Store, VariableStore } from "@ocas/core";
|
||||
import type { Hash, ListOptions, Store, VariableStore } from "@ocas/core";
|
||||
import {
|
||||
bootstrap,
|
||||
CasNodeNotFoundError,
|
||||
@@ -42,6 +42,9 @@ const VALUE_FLAGS = new Set([
|
||||
"epsilon",
|
||||
"inline",
|
||||
"type",
|
||||
"sort",
|
||||
"limit",
|
||||
"offset",
|
||||
]);
|
||||
|
||||
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
@@ -89,6 +92,14 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
|
||||
const { flags, positional } = parseArgs(process.argv.slice(2));
|
||||
|
||||
// --- Handle --version early ---
|
||||
if (flags.version === true) {
|
||||
const pkgPath = join(import.meta.dir, "..", "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
process.stdout.write(`${pkg.version}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const defaultStorePath = join(homedir(), ".ocas");
|
||||
const storePath =
|
||||
typeof flags.home === "string"
|
||||
@@ -118,7 +129,7 @@ async function out(data: unknown, store?: Store): Promise<void> {
|
||||
// varStore is intentionally omitted — inline render uses YAML fallback
|
||||
// only, custom templates require the full `ocas render` command.
|
||||
const output = renderDirect(envelope.type as Hash, envelope.value, s, null);
|
||||
process.stdout.write(output);
|
||||
process.stdout.write(`${output}\n`);
|
||||
return;
|
||||
}
|
||||
console.log(compact ? JSON.stringify(data) : JSON.stringify(data, null, 2));
|
||||
@@ -230,6 +241,62 @@ function parseTagsLabels(args: string[]): {
|
||||
return { tags, labels, deleteNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse --sort/--limit/--offset/--desc into a ListOptions object.
|
||||
* Validates each flag and dies with a clear error on invalid values.
|
||||
*/
|
||||
function parseListOptions(): ListOptions {
|
||||
// Default limit applied at the CLI layer only; core treats undefined as
|
||||
// "no limit" so internal callers (e.g. gc) can fetch full result sets.
|
||||
const opts: ListOptions = { limit: 100 };
|
||||
const sortFlag = flags.sort;
|
||||
if (sortFlag !== undefined) {
|
||||
if (typeof sortFlag !== "string") {
|
||||
die("Error: --sort requires a value (created or updated)");
|
||||
}
|
||||
if (sortFlag !== "created" && sortFlag !== "updated") {
|
||||
die(`Error: --sort must be 'created' or 'updated' (got '${sortFlag}')`);
|
||||
}
|
||||
opts.sort = sortFlag;
|
||||
}
|
||||
const limitFlag = flags.limit;
|
||||
if (limitFlag !== undefined) {
|
||||
if (typeof limitFlag !== "string") {
|
||||
die("Error: --limit requires a numeric value");
|
||||
}
|
||||
const parsed = Number.parseInt(limitFlag, 10);
|
||||
if (
|
||||
!Number.isFinite(parsed) ||
|
||||
parsed < 0 ||
|
||||
String(parsed) !== limitFlag
|
||||
) {
|
||||
die(`Error: --limit must be a non-negative integer (got '${limitFlag}')`);
|
||||
}
|
||||
opts.limit = parsed;
|
||||
}
|
||||
const offsetFlag = flags.offset;
|
||||
if (offsetFlag !== undefined) {
|
||||
if (typeof offsetFlag !== "string") {
|
||||
die("Error: --offset requires a numeric value");
|
||||
}
|
||||
const parsed = Number.parseInt(offsetFlag, 10);
|
||||
if (
|
||||
!Number.isFinite(parsed) ||
|
||||
parsed < 0 ||
|
||||
String(parsed) !== offsetFlag
|
||||
) {
|
||||
die(
|
||||
`Error: --offset must be a non-negative integer (got '${offsetFlag}')`,
|
||||
);
|
||||
}
|
||||
opts.offset = parsed;
|
||||
}
|
||||
if (flags.desc === true) {
|
||||
opts.desc = true;
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ---- Commands ----
|
||||
|
||||
async function cmdPut(args: string[]): Promise<void> {
|
||||
@@ -441,15 +508,7 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
let store: Store;
|
||||
let varStore: VariableStore | undefined;
|
||||
if (isPipe) {
|
||||
store = await openStore();
|
||||
} else {
|
||||
const opened = await openStoreAndVarStore();
|
||||
store = opened.store;
|
||||
varStore = opened.varStore;
|
||||
}
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
|
||||
// Parse numeric options
|
||||
const resolution =
|
||||
@@ -512,35 +571,42 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const output = renderDirect(
|
||||
envelope.type as Hash,
|
||||
envelope.value,
|
||||
store,
|
||||
{
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
},
|
||||
);
|
||||
process.stdout.write(output);
|
||||
} else {
|
||||
if (varStore === undefined) {
|
||||
die("Internal error: varStore not initialized");
|
||||
}
|
||||
try {
|
||||
const hash = resolveHash(input as string, varStore);
|
||||
const output = await renderAsync(store, hash, {
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
// If the envelope value is a hash string (e.g. from `put` output),
|
||||
// resolve it through renderAsync to apply templates and expand refs.
|
||||
// Otherwise, use renderDirect for inline rendering of the envelope value.
|
||||
if (typeof envelope.value === "string" && isHash(envelope.value)) {
|
||||
const output = await renderAsync(store, envelope.value as Hash, {
|
||||
...(resolution !== undefined && { resolution }),
|
||||
...(decay !== undefined && { decay }),
|
||||
...(epsilon !== undefined && { epsilon }),
|
||||
varStore,
|
||||
});
|
||||
// Output to stdout without JSON wrapping (raw output)
|
||||
process.stdout.write(output);
|
||||
} finally {
|
||||
varStore.close();
|
||||
process.stdout.write(`${output}\n`);
|
||||
} else {
|
||||
const output = renderDirect(
|
||||
envelope.type as Hash,
|
||||
envelope.value,
|
||||
store,
|
||||
{
|
||||
...(resolution !== undefined && { resolution }),
|
||||
...(decay !== undefined && { decay }),
|
||||
...(epsilon !== undefined && { epsilon }),
|
||||
},
|
||||
);
|
||||
process.stdout.write(`${output}\n`);
|
||||
}
|
||||
} else {
|
||||
const hash = resolveHash(input as string, varStore);
|
||||
const output = await renderAsync(store, hash, {
|
||||
...(resolution !== undefined && { resolution }),
|
||||
...(decay !== undefined && { decay }),
|
||||
...(epsilon !== undefined && { epsilon }),
|
||||
varStore,
|
||||
});
|
||||
// Output to stdout without JSON wrapping (raw output)
|
||||
process.stdout.write(`${output}\n`);
|
||||
}
|
||||
varStore.close();
|
||||
} catch (error) {
|
||||
if (error instanceof CasNodeNotFoundError) {
|
||||
die(`Error: Node not found: ${error.hash}`);
|
||||
@@ -562,7 +628,9 @@ async function cmdVarSet(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
if (name.startsWith("@ocas/")) {
|
||||
die("The @ocas/ namespace is reserved and cannot be modified directly.");
|
||||
die(
|
||||
"The @ocas/ namespace is reserved and cannot be modified directly. Use a different scope, e.g. @myapp/name (variable names must follow @scope/name format).",
|
||||
);
|
||||
}
|
||||
|
||||
const store = await openStore();
|
||||
@@ -645,7 +713,9 @@ async function cmdVarDelete(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
if (name.startsWith("@ocas/")) {
|
||||
die("The @ocas/ namespace is reserved and cannot be modified directly.");
|
||||
die(
|
||||
"The @ocas/ namespace is reserved and cannot be modified directly. Use a different scope, e.g. @myapp/name (variable names must follow @scope/name format).",
|
||||
);
|
||||
}
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
@@ -697,9 +767,9 @@ async function cmdVarTag(args: string[]): Promise<void> {
|
||||
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
|
||||
|
||||
const variable = varStore.tag(name, schema, {
|
||||
add: Object.keys(tags).length > 0 ? tags : undefined,
|
||||
addLabels: labels.length > 0 ? labels : undefined,
|
||||
delete: deleteNames.length > 0 ? deleteNames : undefined,
|
||||
...(Object.keys(tags).length > 0 && { add: tags }),
|
||||
...(labels.length > 0 && { addLabels: labels }),
|
||||
...(deleteNames.length > 0 && { delete: deleteNames }),
|
||||
});
|
||||
|
||||
await out(
|
||||
@@ -765,64 +835,11 @@ async function cmdVarHistory(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarRollback(args: string[]): Promise<void> {
|
||||
const name = args[0];
|
||||
const positionStr = args[1];
|
||||
const schemaInput = flags.schema as string | undefined;
|
||||
|
||||
if (!name || positionStr === undefined) {
|
||||
die("Usage: ocas var rollback <name> <position> [--schema <hash-or-name>]");
|
||||
}
|
||||
|
||||
if (name.startsWith("@ocas/")) {
|
||||
die("The @ocas/ namespace is reserved and cannot be modified directly.");
|
||||
}
|
||||
|
||||
const position = Number.parseInt(positionStr, 10);
|
||||
if (!Number.isInteger(position) || position < 0) {
|
||||
die(
|
||||
`Error: Invalid position: ${positionStr} (must be a non-negative integer)`,
|
||||
);
|
||||
}
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
|
||||
try {
|
||||
let schema: Hash;
|
||||
if (schemaInput !== undefined) {
|
||||
schema = resolveHash(schemaInput, varStore);
|
||||
} else {
|
||||
const variants = varStore.list({ exactName: name });
|
||||
if (variants.length === 0) {
|
||||
die(`Error: Variable not found: ${name}`);
|
||||
}
|
||||
if (variants.length > 1) {
|
||||
die(
|
||||
`Error: Multiple schema variants for "${name}"; use --schema to disambiguate`,
|
||||
);
|
||||
}
|
||||
schema = (variants[0] as { schema: string }).schema as Hash;
|
||||
}
|
||||
|
||||
const variable = varStore.rollback(name, schema, position);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/var-rollback", variable),
|
||||
store,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof VariableNotFoundError) {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarList(args: string[]): Promise<void> {
|
||||
const namePrefix = args[0] ?? "";
|
||||
const schemaInput = flags.schema as string | undefined;
|
||||
const tagFlags = flags.tag;
|
||||
const listOpts = parseListOptions();
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
|
||||
@@ -846,9 +863,10 @@ async function cmdVarList(args: string[]): Promise<void> {
|
||||
|
||||
const variables = varStore.list({
|
||||
namePrefix,
|
||||
schema,
|
||||
tags: Object.keys(tags).length > 0 ? tags : undefined,
|
||||
labels: labels.length > 0 ? labels : undefined,
|
||||
...(schema !== undefined ? { schema } : {}),
|
||||
...(Object.keys(tags).length > 0 ? { tags } : {}),
|
||||
...(labels.length > 0 ? { labels } : {}),
|
||||
...listOpts,
|
||||
});
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/var-list", variables),
|
||||
@@ -1022,7 +1040,7 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof VariableNotFoundError) {
|
||||
die(`Error: Template not found for schema: ${schemaHash}`);
|
||||
die(`Error: Template not found for schema: ${schemaInput}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
@@ -1046,32 +1064,40 @@ async function cmdList(_args: string[]): Promise<void> {
|
||||
const typeFlag = flags.type;
|
||||
if (typeof typeFlag !== "string")
|
||||
die("Usage: ocas list --type <hash-or-name>");
|
||||
const opts = parseListOptions();
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
try {
|
||||
const typeHash = resolveHash(typeFlag, varStore);
|
||||
const hashes = Array.from(store.listByType(typeHash));
|
||||
await out(await wrapEnvelope(store, "@ocas/output/list", hashes), store);
|
||||
const entries = store.listByType(typeHash, opts);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/list", entries), store);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdListMeta(_args: string[]): Promise<void> {
|
||||
const opts = parseListOptions();
|
||||
const store = await openStore();
|
||||
const hashes = store.listMeta();
|
||||
await out(await wrapEnvelope(store, "@ocas/output/list-meta", hashes), store);
|
||||
const entries = store.listMeta(opts);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/list-meta", entries),
|
||||
store,
|
||||
);
|
||||
}
|
||||
|
||||
async function cmdListSchema(_args: string[]): Promise<void> {
|
||||
const opts = parseListOptions();
|
||||
const store = await openStore();
|
||||
const hashes = store.listSchemas();
|
||||
const entries = store.listSchemas(opts);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/list-schema", hashes),
|
||||
await wrapEnvelope(store, "@ocas/output/list-schema", entries),
|
||||
store,
|
||||
);
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
const pkgPath = join(import.meta.dir, "..", "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
console.log(`\
|
||||
Usage: ocas [--home <path>] [--json] <command> [args]
|
||||
|
||||
@@ -1098,7 +1124,6 @@ Commands:
|
||||
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list)
|
||||
var tag <name> --schema <hash> <operations...> Modify tags/labels (@ocas/output/var-tag)
|
||||
var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history)
|
||||
var rollback <name> <position> [--schema <hash>] Rollback to historical value (@ocas/output/var-rollback)
|
||||
template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set)
|
||||
template get <schema-hash> Get template content (value=string) (@ocas/output/template-get)
|
||||
template list List all templates (@ocas/output/template-list)
|
||||
@@ -1116,7 +1141,9 @@ Flags:
|
||||
--resolution <n> Initial resolution for render (default: 1.0)
|
||||
--decay <n> Decay factor for render (default: 0.5)
|
||||
--epsilon <n> Cutoff threshold for render (default: 0.01)
|
||||
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)`);
|
||||
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)
|
||||
|
||||
Agent: If you have not installed the ocas skill for this version (${pkg.version}), run \`ocas prompt setup\` and follow the instructions.`);
|
||||
}
|
||||
|
||||
// ---- Dispatch ----
|
||||
@@ -1194,9 +1221,6 @@ switch (cmd) {
|
||||
case "history":
|
||||
await cmdVarHistory(subRest);
|
||||
break;
|
||||
case "rollback":
|
||||
await cmdVarRollback(subRest);
|
||||
break;
|
||||
default:
|
||||
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
|
||||
}
|
||||
@@ -1228,6 +1252,33 @@ switch (cmd) {
|
||||
await cmdGc(rest);
|
||||
break;
|
||||
|
||||
case "prompt": {
|
||||
const [sub] = rest;
|
||||
switch (sub) {
|
||||
case "usage": {
|
||||
const content = readFileSync(
|
||||
join(import.meta.dir, "prompts", "usage.md"),
|
||||
"utf-8",
|
||||
);
|
||||
process.stdout.write(content);
|
||||
break;
|
||||
}
|
||||
case "setup": {
|
||||
const content = readFileSync(
|
||||
join(import.meta.dir, "prompts", "setup.md"),
|
||||
"utf-8",
|
||||
);
|
||||
process.stdout.write(content);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
die(
|
||||
`Unknown prompt subcommand: ${sub ?? "(none)"}. Available: usage, setup`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
die(`Unknown command: ${cmd}`);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,197 @@
|
||||
# OCAS — Object Content Addressable Store
|
||||
|
||||
## Overview
|
||||
|
||||
OCAS is a self-describing content-addressable store for typed JSON data. Every node has a `type` field (hash of a JSON Schema) and a `payload`. Hashes are 13-character Crockford Base32 strings (XXH64 over deterministic CBOR).
|
||||
|
||||
All commands output `{ type, value }` JSON envelopes, making them composable via pipes.
|
||||
|
||||
**Install:** `bun add -g @ocas/cli`
|
||||
|
||||
**Packages:** `@ocas/core` (engine) · `@ocas/fs` (filesystem store) · `@ocas/cli` (CLI)
|
||||
|
||||
## When to Use
|
||||
|
||||
- Storing structured, schema-validated JSON data with content addressing
|
||||
- Building knowledge graphs or DAGs with typed nodes and `cas_ref` edges
|
||||
- Agent memory, config versioning, or any use case needing immutable data + mutable pointers
|
||||
- Don't use for: binary blobs, large files, or high-throughput streaming
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Register a schema (from stdin)
|
||||
echo '{
|
||||
"type": "object",
|
||||
"properties": { "title": { "type": "string" }, "done": { "type": "boolean" } },
|
||||
"required": ["title", "done"],
|
||||
"additionalProperties": false
|
||||
}' | ocas put @ocas/schema -p
|
||||
# → { "type": "...", "value": "<schema-hash>" }
|
||||
|
||||
# Name it
|
||||
ocas var set @todo/schema <schema-hash>
|
||||
|
||||
# Store data
|
||||
echo '{ "title": "Buy milk", "done": false }' | ocas put @todo/schema -p
|
||||
|
||||
# Retrieve + verify
|
||||
ocas get <hash>
|
||||
ocas verify <hash>
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Hashes
|
||||
13-char uppercase Crockford Base32 (e.g. `9S7JEYS3FKSDH`). Deterministic: same content → same hash.
|
||||
|
||||
### Envelope Format
|
||||
Every command outputs `{ type, value }`. `type` is the hash of the result schema. Pipe any envelope into `render -p` to render it human-readable.
|
||||
|
||||
### Variables
|
||||
Mutable pointers to immutable data (like git branches → commits). All names must follow `@scope/name` format:
|
||||
- `@myapp/config` ✅
|
||||
- `@ocas/schema` ✅ (builtin, read-only)
|
||||
- `config` ❌ (no scope)
|
||||
|
||||
### Templates
|
||||
LiquidJS templates bound to a schema. `render` uses the template for the node's type, falling back to YAML.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### Store & Retrieve
|
||||
|
||||
```bash
|
||||
ocas put <type> <file> # store node → hash
|
||||
ocas put <type> -p # read payload from stdin
|
||||
ocas get <hash> # retrieve node
|
||||
ocas has <hash> # check existence
|
||||
ocas hash <type> <file> # compute hash without storing
|
||||
ocas verify <hash> # integrity + schema validation
|
||||
```
|
||||
|
||||
### Graph Traversal
|
||||
|
||||
```bash
|
||||
ocas refs <hash> # direct cas_ref edges
|
||||
ocas walk <hash> # recursive DAG traversal
|
||||
ocas walk <hash> --format tree # tree view
|
||||
```
|
||||
|
||||
### Listing & Querying
|
||||
|
||||
```bash
|
||||
ocas list --type <hash|name> # list nodes by type
|
||||
ocas list-schema # all schemas
|
||||
ocas list-meta # meta-schema hashes
|
||||
```
|
||||
|
||||
Sorting and pagination:
|
||||
|
||||
```bash
|
||||
ocas list --type @todo/schema --sort updated --desc --limit 20
|
||||
ocas list --type @todo/schema --offset 20 --limit 20 # page 2
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
```bash
|
||||
ocas var set @myapp/config <hash> # bind name → hash
|
||||
ocas var set @myapp/config <hash> --tag env:prod --tag pinned
|
||||
ocas var get @myapp/config # look up
|
||||
ocas var delete @myapp/config # remove
|
||||
ocas var list [prefix] # list (prefix filter)
|
||||
ocas var list @myapp/ --tag env:prod # filter by scope + tag
|
||||
ocas var history @myapp/config # last 10 values (LRU)
|
||||
ocas var tag @myapp/config --schema <h> status:active # add tag
|
||||
ocas var tag @myapp/config --schema <h> :status # remove tag
|
||||
```
|
||||
|
||||
**Naming rules:**
|
||||
- Format: `@scope/name` — `@[a-zA-Z][a-zA-Z0-9]*/segments`
|
||||
- `@ocas/*` reserved for builtins
|
||||
- Any command accepting a hash also accepts a variable name
|
||||
|
||||
### Templates & Rendering
|
||||
|
||||
```bash
|
||||
ocas template set <schema-hash> --inline "{{ payload.title }}"
|
||||
ocas template get <schema-hash>
|
||||
ocas template list
|
||||
ocas template delete <schema-hash>
|
||||
ocas render <hash> # render with template (or YAML fallback)
|
||||
ocas render --pipe/-p # render from piped envelope
|
||||
ocas get <hash> -r # inline render shorthand
|
||||
```
|
||||
|
||||
Render options: `--resolution N` (max depth), `--decay N` (depth decay), `--epsilon N` (cutoff).
|
||||
|
||||
### Garbage Collection
|
||||
|
||||
```bash
|
||||
ocas gc # collect unreachable nodes
|
||||
ocas gc | ocas render -p # human-readable stats
|
||||
```
|
||||
|
||||
### Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
|
||||
| `--var-db <path>` | Variable database path |
|
||||
| `--json` | Compact JSON output |
|
||||
| `-p`, `--pipe` | Read from stdin |
|
||||
| `-r`, `--render` | Render output inline |
|
||||
| `--sort created\|updated` | Sort key (default: `created`) |
|
||||
| `--limit <n>` | Max results (default: 100) |
|
||||
| `--offset <n>` | Skip first N (default: 0) |
|
||||
| `--desc` | Sort descending |
|
||||
|
||||
## Pipe Composition Patterns
|
||||
|
||||
```bash
|
||||
# Store + render in one go
|
||||
echo '{"title":"test","done":false}' | ocas put @todo/schema -p | ocas render -p
|
||||
|
||||
# Or use -r shorthand
|
||||
ocas get <hash> -r
|
||||
|
||||
# List schemas, extract hashes with jq
|
||||
ocas list --type @ocas/schema | jq -r '.value[].hash'
|
||||
|
||||
# Render GC stats
|
||||
ocas gc | ocas render -p
|
||||
```
|
||||
|
||||
## Library Usage
|
||||
|
||||
```typescript
|
||||
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" });
|
||||
```
|
||||
|
||||
For filesystem persistence:
|
||||
|
||||
```typescript
|
||||
import { openStoreAndVarStore } from "@ocas/fs";
|
||||
const { store, varStore } = await openStoreAndVarStore("/path/to/store");
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Variable names without `@scope/`** — all names must be `@scope/name` format. `config` alone will be rejected.
|
||||
2. **Writing to `@ocas/*` namespace** — reserved for builtins, CLI will reject.
|
||||
3. **Forgetting `-p` for stdin** — `ocas put <type>` expects a file path; use `-p` to read from stdin.
|
||||
4. **Expecting `list` to return hashes** — `list` commands return `ListEntry[]` with `{ hash, created, updated }`, not bare hashes.
|
||||
5. **`workspace:*` in published packages** — only on `main` branch; release branches must have fixed versions.
|
||||
@@ -4,7 +4,7 @@ exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `
|
||||
|
||||
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!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`;
|
||||
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`] = `
|
||||
"Usage: ocas [--home <path>] [--json] <command> [args]
|
||||
@@ -32,7 +32,6 @@ Commands:
|
||||
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list)
|
||||
var tag <name> --schema <hash> <operations...> Modify tags/labels (@ocas/output/var-tag)
|
||||
var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history)
|
||||
var rollback <name> <position> [--schema <hash>] Rollback to historical value (@ocas/output/var-rollback)
|
||||
template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set)
|
||||
template get <schema-hash> Get template content (value=string) (@ocas/output/template-get)
|
||||
template list List all templates (@ocas/output/template-list)
|
||||
@@ -58,7 +57,7 @@ exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||
"type": "0Q5EMYK4SYSS9",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {},
|
||||
"value": "9W3MGR3184QYE",
|
||||
@@ -71,7 +70,7 @@ exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
"type": "7C75FQT98KKQD",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {},
|
||||
"value": "9W3MGR3184QYE",
|
||||
@@ -200,21 +199,21 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
"name": "@ocas/output/list",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "CTCEXSNPWMAQQ",
|
||||
"value": "7BWZ3JKKMSH4N",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/list-meta",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "0V41JBWK72HS3",
|
||||
"value": "1WQ7C0EV8QGA4",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/list-schema",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "AW24Q8BKXQYTE",
|
||||
"value": "7FYGS2KQ3REM9",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
@@ -258,13 +257,6 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
"tags": {},
|
||||
"value": "EVZJS80TRFKE1",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-rollback",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "3S1EZX570VBHF",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/template-set",
|
||||
@@ -302,7 +294,7 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {},
|
||||
"value": "9W3MGR3184QYE",
|
||||
@@ -317,7 +309,7 @@ exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {},
|
||||
"value": "9W3MGR3184QYE",
|
||||
@@ -331,7 +323,7 @@ exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1
|
||||
"type": "0Q5EMYK4SYSS9",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {},
|
||||
"value": "A6QPKJAFR68NP",
|
||||
@@ -346,7 +338,7 @@ exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
@@ -364,7 +356,7 @@ exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
@@ -383,7 +375,7 @@ exports[`Phase 3: Variable System 3.8 var list --tag important filters by label
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
@@ -399,7 +391,7 @@ exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
"type": "9103EYRMM949A",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
@@ -415,7 +407,7 @@ exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
@@ -426,7 +418,7 @@ 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`] = `
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("ocas binary", () => {
|
||||
test("T2: no legacy bin entries (json-cas, ucas)", async () => {
|
||||
const pkg = await Bun.file(pkgPath).json();
|
||||
expect(pkg.bin["json-cas"]).toBeUndefined();
|
||||
expect(pkg.bin["ucas"]).toBeUndefined();
|
||||
expect(pkg.bin.ucas).toBeUndefined();
|
||||
expect(Object.keys(pkg.bin)).toEqual(["ocas"]);
|
||||
});
|
||||
|
||||
@@ -210,7 +210,7 @@ describe("Phase 3: Variable System", () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"set",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
nodeHash,
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
@@ -221,7 +221,7 @@ describe("Phase 3: Variable System", () => {
|
||||
const { stdout, exitCode } = await runCli([
|
||||
"var",
|
||||
"get",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
]);
|
||||
@@ -234,14 +234,14 @@ describe("Phase 3: Variable System", () => {
|
||||
const { stdout, exitCode } = await runCli(["var", "list"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
expect(stdout).toContain("myapp/config");
|
||||
expect(stdout).toContain("@myapp/config");
|
||||
});
|
||||
|
||||
test("3.4 var list prefix filters by prefix", async () => {
|
||||
const { stdout, exitCode } = await runCli(["var", "list", "myapp/"]);
|
||||
const { stdout, exitCode } = await runCli(["var", "list", "@myapp/"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
expect(stdout).toContain("myapp/config");
|
||||
expect(stdout).toContain("@myapp/config");
|
||||
});
|
||||
|
||||
test("3.5 var set upsert updates existing variable", async () => {
|
||||
@@ -252,20 +252,20 @@ describe("Phase 3: Variable System", () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"set",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
node2Hash,
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
// Restore original value
|
||||
await runCli(["var", "set", "myapp/config", nodeHash]);
|
||||
await runCli(["var", "set", "@myapp/config", nodeHash]);
|
||||
});
|
||||
|
||||
test("3.6 var tag adds kv tag and label", async () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"tag",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"env:prod",
|
||||
@@ -283,7 +283,7 @@ describe("Phase 3: Variable System", () => {
|
||||
"env:prod",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("myapp/config");
|
||||
expect(stdout).toContain("@myapp/config");
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -295,7 +295,7 @@ describe("Phase 3: Variable System", () => {
|
||||
"important",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("myapp/config");
|
||||
expect(stdout).toContain("@myapp/config");
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -303,7 +303,7 @@ describe("Phase 3: Variable System", () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"tag",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
":important",
|
||||
@@ -317,14 +317,14 @@ describe("Phase 3: Variable System", () => {
|
||||
"--tag",
|
||||
"important",
|
||||
]);
|
||||
expect(listOut).not.toContain("myapp/config");
|
||||
expect(listOut).not.toContain("@myapp/config");
|
||||
});
|
||||
|
||||
test("3.10 var delete removes variable", async () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"delete",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
@@ -334,7 +334,7 @@ describe("Phase 3: Variable System", () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"var",
|
||||
"get",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
]);
|
||||
|
||||
@@ -39,7 +39,7 @@ beforeAll(async () => {
|
||||
nodeHash = envValue(stdout) as string;
|
||||
|
||||
// Set a var referencing the node so it survives GC
|
||||
await runCli(["var", "set", "gc-test/ref", nodeHash]);
|
||||
await runCli(["var", "set", "@test/gc-test/ref", nodeHash]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -33,18 +33,22 @@ describe("list-meta CLI command", () => {
|
||||
["list", "--type", "@ocas/schema"],
|
||||
storePath,
|
||||
);
|
||||
const schemaList = envValue(schemaListOut) as string[];
|
||||
const schemaList = envValue(schemaListOut) as Array<{ hash: string }>;
|
||||
expect(Array.isArray(schemaList)).toBe(true);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(["list-meta"], storePath);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
|
||||
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
type: string;
|
||||
value: Array<{ hash: string; created: number; updated: number }>;
|
||||
};
|
||||
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
expect(Array.isArray(parsed.value)).toBe(true);
|
||||
expect(parsed.value).toHaveLength(1);
|
||||
expect(parsed.value[0]).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
const first = parsed.value[0] as { hash: string };
|
||||
expect(first.hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("E1. --json flag yields compact JSON", async () => {
|
||||
@@ -136,10 +140,12 @@ describe("E4. list-schema vs list --type with multiple meta-schema versions", ()
|
||||
storePath,
|
||||
);
|
||||
expect(lsCode).toBe(0);
|
||||
const lsValue = envValue(lsOut) as string[];
|
||||
expect(lsValue).toContain(sM1);
|
||||
expect(lsValue).toContain(m1);
|
||||
expect(lsValue).toContain(m2);
|
||||
const lsHashes = (envValue(lsOut) as Array<{ hash: string }>).map(
|
||||
(e) => e.hash,
|
||||
);
|
||||
expect(lsHashes).toContain(sM1);
|
||||
expect(lsHashes).toContain(m1);
|
||||
expect(lsHashes).toContain(m2);
|
||||
|
||||
// CLI: list --type <M2 hash> must NOT include sM1
|
||||
const { stdout: ltOut, exitCode: ltCode } = await runCli(
|
||||
@@ -147,10 +153,12 @@ describe("E4. list-schema vs list --type with multiple meta-schema versions", ()
|
||||
storePath,
|
||||
);
|
||||
expect(ltCode).toBe(0);
|
||||
const ltValue = envValue(ltOut) as string[];
|
||||
expect(ltValue).not.toContain(sM1);
|
||||
const ltHashes = (envValue(ltOut) as Array<{ hash: string }>).map(
|
||||
(e) => e.hash,
|
||||
);
|
||||
expect(ltHashes).not.toContain(sM1);
|
||||
|
||||
// list-schema.value.length > list --type <newest>.value.length
|
||||
expect(lsValue.length).toBeGreaterThan(ltValue.length);
|
||||
expect(lsHashes.length).toBeGreaterThan(ltHashes.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { envValue, runCli } from "./helpers.js";
|
||||
|
||||
const HASH_RE = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||
|
||||
let storePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
storePath = mkdtempSync(join(tmpdir(), "ocas-list-pagination-"));
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(storePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function putString(text: string): Promise<string> {
|
||||
// Use the @ocas/string built-in via library; CLI doesn't expose put-text but
|
||||
// `put @ocas/string --pipe` works. We'll use --pipe with stdin.
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
join(import.meta.dir, "../src/index.ts"),
|
||||
"--home",
|
||||
storePath,
|
||||
"put",
|
||||
"@ocas/string",
|
||||
"--pipe",
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe", stdin: "pipe" },
|
||||
);
|
||||
proc.stdin.write(JSON.stringify(text));
|
||||
proc.stdin.end();
|
||||
await proc.exited;
|
||||
const out = await new Response(proc.stdout).text();
|
||||
return (JSON.parse(out) as { value: string }).value;
|
||||
}
|
||||
|
||||
async function makeN(n: number, gap = 2): Promise<string[]> {
|
||||
const hashes: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
hashes.push(await putString(`val-${i}-${Math.random()}`));
|
||||
if (gap > 0 && i < n - 1) {
|
||||
await new Promise((r) => setTimeout(r, gap));
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
describe("CLI list --type pagination", () => {
|
||||
test("E1. entries are {hash, created, updated} objects", async () => {
|
||||
await makeN(3);
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["list", "--type", "@ocas/string"],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const value = envValue(stdout) as Array<{
|
||||
hash: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}>;
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
for (const e of value) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
expect(typeof e.updated).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("E2. --limit 2", async () => {
|
||||
await makeN(5, 0);
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--limit", "2"],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const value = envValue(stdout) as unknown[];
|
||||
expect(value).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("E3. --offset 1 skips first", async () => {
|
||||
await makeN(3);
|
||||
const { stdout: a } = await runCli(
|
||||
["list", "--type", "@ocas/string"],
|
||||
storePath,
|
||||
);
|
||||
const { stdout: b } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--offset", "1", "--limit", "100"],
|
||||
storePath,
|
||||
);
|
||||
const all = envValue(a) as Array<{ hash: string }>;
|
||||
const skip = envValue(b) as Array<{ hash: string }>;
|
||||
expect(skip).toHaveLength(all.length - 1);
|
||||
expect((skip[0] as { hash: string }).hash).toBe(
|
||||
(all[1] as { hash: string }).hash,
|
||||
);
|
||||
});
|
||||
|
||||
test("E4. --desc reverses default order", async () => {
|
||||
await makeN(3);
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--desc"],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const value = envValue(stdout) as Array<{ created: number }>;
|
||||
for (let i = 1; i < value.length; i++) {
|
||||
expect((value[i] as { created: number }).created).toBeLessThanOrEqual(
|
||||
(value[i - 1] as { created: number }).created,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("E5. --sort updated accepted; equals created for CAS", async () => {
|
||||
await makeN(3);
|
||||
const { stdout: a } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--sort", "created"],
|
||||
storePath,
|
||||
);
|
||||
const { stdout: b } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--sort", "updated"],
|
||||
storePath,
|
||||
);
|
||||
expect(envValue(a)).toEqual(envValue(b));
|
||||
});
|
||||
|
||||
test("E7. invalid --sort exits non-zero", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--sort", "foo"],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("--sort");
|
||||
});
|
||||
|
||||
test("E8. invalid --limit exits non-zero", async () => {
|
||||
const r1 = await runCli(
|
||||
["list", "--type", "@ocas/string", "--limit", "-1"],
|
||||
storePath,
|
||||
);
|
||||
expect(r1.exitCode).not.toBe(0);
|
||||
expect(r1.stderr).toContain("--limit");
|
||||
|
||||
const r2 = await runCli(
|
||||
["list", "--type", "@ocas/string", "--limit", "abc"],
|
||||
storePath,
|
||||
);
|
||||
expect(r2.exitCode).not.toBe(0);
|
||||
expect(r2.stderr).toContain("--limit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI list-meta / list-schema pagination", () => {
|
||||
test("F1. list-meta entries are objects", async () => {
|
||||
// Bootstrap implicitly happens when running any cli command that opens store
|
||||
await runCli(["list-meta"], storePath);
|
||||
const { stdout } = await runCli(["list-meta"], storePath);
|
||||
const value = envValue(stdout) as Array<{
|
||||
hash: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}>;
|
||||
expect(value.length).toBeGreaterThanOrEqual(1);
|
||||
for (const e of value) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("F2. list-schema --limit honored", async () => {
|
||||
const { stdout } = await runCli(["list-schema", "--limit", "3"], storePath);
|
||||
const value = envValue(stdout) as unknown[];
|
||||
expect(value).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("F3. list-schema --desc reverses order", async () => {
|
||||
const { stdout: asc } = await runCli(["list-schema"], storePath);
|
||||
const { stdout: desc } = await runCli(["list-schema", "--desc"], storePath);
|
||||
const a = envValue(asc) as Array<{ hash: string }>;
|
||||
const d = envValue(desc) as Array<{ hash: string }>;
|
||||
expect(d[0]?.hash).toBe(a[a.length - 1]?.hash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI var list pagination", () => {
|
||||
test("G1./G2. --limit and --offset on var list", async () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const h = await putString(`tval-${i}`);
|
||||
await runCli(["var", "set", `@test/myvar-${i}`, h], storePath);
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
const { stdout: lim } = await runCli(
|
||||
["var", "list", "@test/myvar-", "--limit", "2"],
|
||||
storePath,
|
||||
);
|
||||
expect((envValue(lim) as unknown[]).length).toBe(2);
|
||||
|
||||
const { stdout: off } = await runCli(
|
||||
["var", "list", "@test/myvar-", "--offset", "1", "--limit", "10"],
|
||||
storePath,
|
||||
);
|
||||
const offList = envValue(off) as Array<{ name: string }>;
|
||||
expect(offList.length).toBe(3);
|
||||
expect(offList[0]?.name).toBe("@test/myvar-1");
|
||||
});
|
||||
|
||||
test("G3. --desc reverses var list order", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const h = await putString(`dval-${i}`);
|
||||
await runCli(["var", "set", `@test/dv-${i}`, h], storePath);
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
const { stdout } = await runCli(
|
||||
["var", "list", "@test/dv-", "--desc"],
|
||||
storePath,
|
||||
);
|
||||
const list = envValue(stdout) as Array<{ name: string }>;
|
||||
expect(list[0]?.name).toBe("@test/dv-2");
|
||||
});
|
||||
|
||||
test("G4. --sort updated", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const h = await putString(`sval-${i}`);
|
||||
await runCli(["var", "set", `@test/sv-${i}`, h], storePath);
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
// Re-set sv-0 with NEW value to bump updated
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
const newH = await putString("sval-0-new");
|
||||
await runCli(["var", "set", "@test/sv-0", newH], storePath);
|
||||
|
||||
const { stdout } = await runCli(
|
||||
["var", "list", "@test/sv-", "--sort", "updated"],
|
||||
storePath,
|
||||
);
|
||||
const list = envValue(stdout) as Array<{ name: string }>;
|
||||
expect(list[list.length - 1]?.name).toBe("@test/sv-0");
|
||||
});
|
||||
|
||||
test("G6. invalid --sort exits non-zero", async () => {
|
||||
const r = await runCli(["var", "list", "--sort", "bogus"], storePath);
|
||||
expect(r.exitCode).not.toBe(0);
|
||||
expect(r.stderr).toContain("--sort");
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,14 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { envValue, stripVolatile } from "./helpers";
|
||||
import { envValue } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
let _nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
@@ -36,7 +36,7 @@ beforeAll(async () => {
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const { stdout } = await runCli(["put", typeHash, nodeFile]);
|
||||
nodeHash = envValue(stdout) as string;
|
||||
_nodeHash = envValue(stdout) as string;
|
||||
|
||||
// Set up template for render tests
|
||||
const tmplFile = join(tmpStore, "render-template.liquid");
|
||||
@@ -109,11 +109,11 @@ describe("Phase 8: Pipe Composition", () => {
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Downstream consumers (jq, etc.) read the `value` array of hashes.
|
||||
const value = envValue(stdout) as string[];
|
||||
// Downstream consumers (jq, etc.) read the `value` array of {hash,...}.
|
||||
const value = envValue(stdout) as Array<{ hash: string }>;
|
||||
expect(Array.isArray(value)).toBe(true);
|
||||
for (const hash of value) {
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
for (const entry of value) {
|
||||
expect(entry.hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ describe("Phase 1: CAS Core", () => {
|
||||
test("1.13 list --type returns nodes of that type", async () => {
|
||||
const { stdout, exitCode } = await runCli(["list", "--type", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toContain(nodeHash);
|
||||
const value = envValue(stdout) as Array<{ hash: string }>;
|
||||
expect(value.map((e) => e.hash)).toContain(nodeHash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("Phase 5: Render", () => {
|
||||
return { stdout, stderr, exitCode };
|
||||
}
|
||||
|
||||
async function runCliE2eWithStdin(
|
||||
async function _runCliE2eWithStdin(
|
||||
args: string[],
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
@@ -198,7 +198,7 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output).toBe("Hello Alice!");
|
||||
expect(output).toBe("Hello Alice!\n");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
@@ -321,7 +321,7 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output).toBe("Greetings Bob!");
|
||||
expect(output).toBe("Greetings Bob!\n");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -558,7 +558,7 @@ describe("Phase 2: Schema Validation", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
let _nodeHash: string;
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
@@ -602,7 +602,7 @@ describe("Phase 2: Schema Validation", () => {
|
||||
);
|
||||
await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
nodeHash = envValue(stdout) as string;
|
||||
_nodeHash = envValue(stdout) as string;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -87,13 +87,13 @@ describe("var history", () => {
|
||||
const { schema, values } = await setupSchemaAndValues();
|
||||
const v1 = values[0] as Hash;
|
||||
|
||||
let r = await runCli("var", "set", "x", v1);
|
||||
let r = await runCli("var", "set", "@test/x", v1);
|
||||
expect(r.exitCode).toBe(0);
|
||||
|
||||
r = await runCli("var", "history", "x", "--schema", schema);
|
||||
r = await runCli("var", "history", "@test/x", "--schema", schema);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
expect(envelope.value.name).toBe("x");
|
||||
expect(envelope.value.name).toBe("@test/x");
|
||||
expect(envelope.value.schema).toBe(schema);
|
||||
expect(envelope.value.values).toEqual([v1]);
|
||||
});
|
||||
@@ -102,11 +102,11 @@ describe("var history", () => {
|
||||
const { schema, values } = await setupSchemaAndValues();
|
||||
const [v1, v2, v3] = values as [Hash, Hash, Hash, Hash];
|
||||
|
||||
await runCli("var", "set", "x", v1);
|
||||
await runCli("var", "set", "x", v2);
|
||||
await runCli("var", "set", "x", v3);
|
||||
await runCli("var", "set", "@test/x", v1);
|
||||
await runCli("var", "set", "@test/x", v2);
|
||||
await runCli("var", "set", "@test/x", v3);
|
||||
|
||||
const r = await runCli("var", "history", "x", "--schema", schema);
|
||||
const r = await runCli("var", "history", "@test/x", "--schema", schema);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
expect(envelope.value.values).toEqual([v3, v2, v1]);
|
||||
@@ -116,10 +116,10 @@ describe("var history", () => {
|
||||
const { schema: _schema, values } = await setupSchemaAndValues();
|
||||
const [v1, v2] = values as [Hash, Hash, Hash, Hash];
|
||||
|
||||
await runCli("var", "set", "x", v1);
|
||||
await runCli("var", "set", "x", v2);
|
||||
await runCli("var", "set", "@test/x", v1);
|
||||
await runCli("var", "set", "@test/x", v2);
|
||||
|
||||
const r = await runCli("var", "history", "x");
|
||||
const r = await runCli("var", "history", "@test/x");
|
||||
expect(r.exitCode).toBe(0);
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
expect(envelope.value.values).toEqual([v2, v1]);
|
||||
@@ -131,46 +131,3 @@ describe("var history", () => {
|
||||
expect(r.stderr).toContain("Variable not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("var rollback", () => {
|
||||
test("rolls back to a previous value", async () => {
|
||||
const { schema, values } = await setupSchemaAndValues();
|
||||
const [v1, v2, v3] = values as [Hash, Hash, Hash, Hash];
|
||||
|
||||
await runCli("var", "set", "x", v1);
|
||||
await runCli("var", "set", "x", v2);
|
||||
await runCli("var", "set", "x", v3);
|
||||
|
||||
// History: [v3, v2, v1] — rollback to position 2 (v1)
|
||||
const r = await runCli("var", "rollback", "x", "2", "--schema", schema);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
expect(envelope.value.value).toBe(v1);
|
||||
|
||||
// After rollback, history is [v1, v3, v2]
|
||||
const h = await runCli("var", "history", "x", "--schema", schema);
|
||||
expect(JSON.parse(h.stdout).value.values).toEqual([v1, v3, v2]);
|
||||
});
|
||||
|
||||
test("rollback fails for invalid position", async () => {
|
||||
const { schema: _schema, values } = await setupSchemaAndValues();
|
||||
const v1 = values[0] as Hash;
|
||||
await runCli("var", "set", "x", v1);
|
||||
|
||||
const r = await runCli("var", "rollback", "x", "9");
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toContain("not found");
|
||||
});
|
||||
|
||||
test("rollback fails for negative position", async () => {
|
||||
const r = await runCli("var", "rollback", "x", "-1");
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toContain("Invalid position");
|
||||
});
|
||||
|
||||
test("rollback rejects @ocas/ namespace", async () => {
|
||||
const r = await runCli("var", "rollback", "@ocas/string", "1");
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toContain("@ocas/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("var set", () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"workflow/config/agent",
|
||||
"@test/workflow/config/agent",
|
||||
hash,
|
||||
);
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("var set", () => {
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope).toHaveProperty("type");
|
||||
expect(envelope).toHaveProperty("value");
|
||||
expect(envelope.value.name).toBe("workflow/config/agent");
|
||||
expect(envelope.value.name).toBe("@test/workflow/config/agent");
|
||||
expect(envelope.value.schema).toBe(typeHash);
|
||||
expect(envelope.value.value).toBe(hash);
|
||||
expect(envelope.value.tags).toEqual({});
|
||||
@@ -132,7 +132,7 @@ describe("var set", () => {
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"my/var",
|
||||
"@test/my/var",
|
||||
hash,
|
||||
"--tag",
|
||||
"env:prod",
|
||||
@@ -155,7 +155,7 @@ describe("var set", () => {
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"my/var",
|
||||
"@test/my/var",
|
||||
hash,
|
||||
"--tag",
|
||||
"stable",
|
||||
@@ -177,13 +177,18 @@ describe("var set", () => {
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
|
||||
// Create initial variable
|
||||
await runCli("var", "set", "config", hash1);
|
||||
await runCli("var", "set", "@test/config", hash1);
|
||||
|
||||
// Wait a bit to ensure updated > created
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Update with hash2
|
||||
const { stdout, exitCode } = await runCli("var", "set", "config", hash2);
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"@test/config",
|
||||
hash2,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -200,10 +205,15 @@ describe("var set", () => {
|
||||
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
|
||||
|
||||
// Create first variant
|
||||
await runCli("var", "set", "config", hash1);
|
||||
await runCli("var", "set", "@test/config", hash1);
|
||||
|
||||
// Create second variant with different schema
|
||||
const { stdout, exitCode } = await runCli("var", "set", "config", hash2);
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"@test/config",
|
||||
hash2,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -211,7 +221,7 @@ describe("var set", () => {
|
||||
expect(envelope.value.schema).toBe(typeHash2);
|
||||
|
||||
// Verify both variants exist
|
||||
const { stdout: listOut } = await runCli("var", "list", "config");
|
||||
const { stdout: listOut } = await runCli("var", "list", "@test/config");
|
||||
const listEnvelope = JSON.parse(listOut);
|
||||
expect(listEnvelope.value.length).toBe(2);
|
||||
});
|
||||
@@ -222,13 +232,13 @@ describe("var set", () => {
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create with initial tags/labels
|
||||
await runCli("var", "set", "x", hash, "--tag", "a:1", "--tag", "b");
|
||||
await runCli("var", "set", "@test/x", hash, "--tag", "a:1", "--tag", "b");
|
||||
|
||||
// Update with new tags
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"x",
|
||||
"@test/x",
|
||||
hash,
|
||||
"--tag",
|
||||
"c:2",
|
||||
@@ -245,7 +255,7 @@ describe("var set", () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"my/var",
|
||||
"@test/my/var",
|
||||
"NONEXISTENT_HASH",
|
||||
);
|
||||
|
||||
@@ -287,7 +297,7 @@ describe("var set", () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"x",
|
||||
"@test/x",
|
||||
hash,
|
||||
"--tag",
|
||||
"env:prod",
|
||||
@@ -307,13 +317,13 @@ describe("var get", () => {
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable first
|
||||
await runCli("var", "set", "config", hash);
|
||||
await runCli("var", "set", "@test/config", hash);
|
||||
|
||||
// Get it back
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"config",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
);
|
||||
@@ -321,7 +331,7 @@ describe("var get", () => {
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.name).toBe("config");
|
||||
expect(envelope.value.name).toBe("@test/config");
|
||||
expect(envelope.value.schema).toBe(typeHash);
|
||||
});
|
||||
|
||||
@@ -332,19 +342,19 @@ describe("var get", () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"nonexistent",
|
||||
"@test/nonexistent",
|
||||
"--schema",
|
||||
typeHash,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain(
|
||||
`Error: Variable not found: name=nonexistent, schema=${typeHash}`,
|
||||
`Error: Variable not found: name=@test/nonexistent, schema=${typeHash}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("error when --schema missing", async () => {
|
||||
const { stderr, exitCode } = await runCli("var", "get", "config");
|
||||
const { stderr, exitCode } = await runCli("var", "get", "@test/config");
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain(
|
||||
@@ -360,17 +370,29 @@ describe("var get", () => {
|
||||
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
|
||||
|
||||
// Create two variants
|
||||
await runCli("var", "set", "config", hash1);
|
||||
await runCli("var", "set", "config", hash2);
|
||||
await runCli("var", "set", "@test/config", hash1);
|
||||
await runCli("var", "set", "@test/config", hash2);
|
||||
|
||||
// Get first variant
|
||||
const result1 = await runCli("var", "get", "config", "--schema", typeHash1);
|
||||
const result1 = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
typeHash1,
|
||||
);
|
||||
expect(result1.exitCode).toBe(0);
|
||||
const envelope1 = JSON.parse(result1.stdout);
|
||||
expect(envelope1.value.value).toBe(hash1);
|
||||
|
||||
// Get second variant
|
||||
const result2 = await runCli("var", "get", "config", "--schema", typeHash2);
|
||||
const result2 = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
typeHash2,
|
||||
);
|
||||
expect(result2.exitCode).toBe(0);
|
||||
const envelope2 = JSON.parse(result2.stdout);
|
||||
expect(envelope2.value.value).toBe(hash2);
|
||||
@@ -386,11 +408,11 @@ describe("var delete", () => {
|
||||
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
|
||||
|
||||
// Create two variants
|
||||
await runCli("var", "set", "config", hash1);
|
||||
await runCli("var", "set", "config", hash2);
|
||||
await runCli("var", "set", "@test/config", hash1);
|
||||
await runCli("var", "set", "@test/config", hash2);
|
||||
|
||||
// Delete all
|
||||
const { stdout, exitCode } = await runCli("var", "delete", "config");
|
||||
const { stdout, exitCode } = await runCli("var", "delete", "@test/config");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -399,10 +421,22 @@ describe("var delete", () => {
|
||||
expect(envelope.value.length).toBe(2);
|
||||
|
||||
// Verify both are deleted
|
||||
const result1 = await runCli("var", "get", "config", "--schema", typeHash1);
|
||||
const result1 = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
typeHash1,
|
||||
);
|
||||
expect(result1.exitCode).toBe(1);
|
||||
|
||||
const result2 = await runCli("var", "get", "config", "--schema", typeHash2);
|
||||
const result2 = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
typeHash2,
|
||||
);
|
||||
expect(result2.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
@@ -414,14 +448,14 @@ describe("var delete", () => {
|
||||
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
|
||||
|
||||
// Create two variants
|
||||
await runCli("var", "set", "config", hash1);
|
||||
await runCli("var", "set", "config", hash2);
|
||||
await runCli("var", "set", "@test/config", hash1);
|
||||
await runCli("var", "set", "@test/config", hash2);
|
||||
|
||||
// Delete only first variant
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"delete",
|
||||
"config",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
typeHash1,
|
||||
);
|
||||
@@ -432,16 +466,32 @@ describe("var delete", () => {
|
||||
expect(envelope.value.schema).toBe(typeHash1);
|
||||
|
||||
// Verify first is deleted
|
||||
const result1 = await runCli("var", "get", "config", "--schema", typeHash1);
|
||||
const result1 = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
typeHash1,
|
||||
);
|
||||
expect(result1.exitCode).toBe(1);
|
||||
|
||||
// Verify second still exists
|
||||
const result2 = await runCli("var", "get", "config", "--schema", typeHash2);
|
||||
const result2 = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
typeHash2,
|
||||
);
|
||||
expect(result2.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("return empty array when name not found", async () => {
|
||||
const { stdout, exitCode } = await runCli("var", "delete", "nonexistent");
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"delete",
|
||||
"@test/nonexistent",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -454,14 +504,14 @@ describe("var delete", () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"delete",
|
||||
"config",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
"00000000000ZZ",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain(
|
||||
"Error: Variable not found: name=config, schema=00000000000ZZ",
|
||||
"Error: Variable not found: name=@test/config, schema=00000000000ZZ",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -471,13 +521,13 @@ describe("var delete", () => {
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable with tags and labels
|
||||
await runCli("var", "set", "x", hash, "--tag", "a:1", "--tag", "b");
|
||||
await runCli("var", "set", "@test/x", hash, "--tag", "a:1", "--tag", "b");
|
||||
|
||||
// Delete it
|
||||
const { exitCode } = await runCli(
|
||||
"var",
|
||||
"delete",
|
||||
"x",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
);
|
||||
@@ -485,7 +535,7 @@ describe("var delete", () => {
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify it's gone
|
||||
const result = await runCli("var", "get", "x", "--schema", typeHash);
|
||||
const result = await runCli("var", "get", "@test/x", "--schema", typeHash);
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -500,12 +550,12 @@ describe("var list", () => {
|
||||
|
||||
// Create three variables (use a prefix to filter out builtin @ocas/* vars
|
||||
// that bootstrap writes into the varStore)
|
||||
await runCli("var", "set", "test/a", hash1);
|
||||
await runCli("var", "set", "test/b", hash2);
|
||||
await runCli("var", "set", "test/c", hash3);
|
||||
await runCli("var", "set", "@test/test/a", hash1);
|
||||
await runCli("var", "set", "@test/test/b", hash2);
|
||||
await runCli("var", "set", "@test/test/c", hash3);
|
||||
|
||||
// List all under our prefix
|
||||
const { stdout, exitCode } = await runCli("var", "list", "test/");
|
||||
const { stdout, exitCode } = await runCli("var", "list", "@test/test/");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -522,19 +572,19 @@ describe("var list", () => {
|
||||
const hash3 = await createTestNode(store, typeHash, { test: "data3" });
|
||||
|
||||
// Create variables with different prefixes
|
||||
await runCli("var", "set", "workflow/config/agent", hash1);
|
||||
await runCli("var", "set", "workflow/config/model", hash2);
|
||||
await runCli("var", "set", "other/var", hash3);
|
||||
await runCli("var", "set", "@test/workflow/config/agent", hash1);
|
||||
await runCli("var", "set", "@test/workflow/config/model", hash2);
|
||||
await runCli("var", "set", "@test/other/var", hash3);
|
||||
|
||||
// List with prefix
|
||||
const { stdout, exitCode } = await runCli("var", "list", "workflow/");
|
||||
const { stdout, exitCode } = await runCli("var", "list", "@test/workflow/");
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.length).toBe(2);
|
||||
expect(envelope.value[0].name).toContain("workflow/");
|
||||
expect(envelope.value[1].name).toContain("workflow/");
|
||||
expect(envelope.value[0].name).toContain("@test/workflow/");
|
||||
expect(envelope.value[1].name).toContain("@test/workflow/");
|
||||
});
|
||||
|
||||
test("filter by schema", async () => {
|
||||
@@ -554,8 +604,8 @@ describe("var list", () => {
|
||||
void bootstrapHash;
|
||||
|
||||
// Create variables with different schemas
|
||||
await runCli("var", "set", "a", hash1);
|
||||
await runCli("var", "set", "b", hash2);
|
||||
await runCli("var", "set", "@test/a", hash1);
|
||||
await runCli("var", "set", "@test/b", hash2);
|
||||
|
||||
// List with schema filter
|
||||
const { stdout, exitCode } = await runCli(
|
||||
@@ -583,7 +633,7 @@ describe("var list", () => {
|
||||
await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"a",
|
||||
"@test/a",
|
||||
hash1,
|
||||
"--tag",
|
||||
"env:prod",
|
||||
@@ -593,14 +643,14 @@ describe("var list", () => {
|
||||
await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"b",
|
||||
"@test/b",
|
||||
hash2,
|
||||
"--tag",
|
||||
"env:prod",
|
||||
"--tag",
|
||||
"region:eu",
|
||||
);
|
||||
await runCli("var", "set", "c", hash3, "--tag", "env:dev");
|
||||
await runCli("var", "set", "@test/c", hash3, "--tag", "env:dev");
|
||||
|
||||
// List with tag filter (AND logic)
|
||||
const { stdout, exitCode } = await runCli(
|
||||
@@ -616,7 +666,7 @@ describe("var list", () => {
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.length).toBe(1);
|
||||
expect(envelope.value[0].name).toBe("a");
|
||||
expect(envelope.value[0].name).toBe("@test/a");
|
||||
});
|
||||
|
||||
test("filter by labels", async () => {
|
||||
@@ -629,14 +679,14 @@ describe("var list", () => {
|
||||
await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"a",
|
||||
"@test/a",
|
||||
hash1,
|
||||
"--tag",
|
||||
"stable",
|
||||
"--tag",
|
||||
"production",
|
||||
);
|
||||
await runCli("var", "set", "b", hash2, "--tag", "stable");
|
||||
await runCli("var", "set", "@test/b", hash2, "--tag", "stable");
|
||||
|
||||
// List with single label
|
||||
const result1 = await runCli("var", "list", "--tag", "stable");
|
||||
@@ -656,7 +706,7 @@ describe("var list", () => {
|
||||
expect(result2.exitCode).toBe(0);
|
||||
envelope = JSON.parse(result2.stdout);
|
||||
expect(envelope.value.length).toBe(1);
|
||||
expect(envelope.value[0].name).toBe("a");
|
||||
expect(envelope.value[0].name).toBe("@test/a");
|
||||
});
|
||||
|
||||
test("combined filters", async () => {
|
||||
@@ -667,15 +717,15 @@ describe("var list", () => {
|
||||
const hash3 = await createTestNode(store, typeHash1, { test: "data3" });
|
||||
|
||||
// Create variables
|
||||
await runCli("var", "set", "workflow/a", hash1, "--tag", "env:prod");
|
||||
await runCli("var", "set", "workflow/b", hash2, "--tag", "env:dev");
|
||||
await runCli("var", "set", "other/c", hash3, "--tag", "env:prod");
|
||||
await runCli("var", "set", "@test/workflow/a", hash1, "--tag", "env:prod");
|
||||
await runCli("var", "set", "@test/workflow/b", hash2, "--tag", "env:dev");
|
||||
await runCli("var", "set", "@test/other/c", hash3, "--tag", "env:prod");
|
||||
|
||||
// List with combined filters
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"list",
|
||||
"workflow/",
|
||||
"@test/workflow/",
|
||||
"--schema",
|
||||
typeHash1,
|
||||
"--tag",
|
||||
@@ -686,14 +736,14 @@ describe("var list", () => {
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.length).toBe(1);
|
||||
expect(envelope.value[0].name).toBe("workflow/a");
|
||||
expect(envelope.value[0].name).toBe("@test/workflow/a");
|
||||
});
|
||||
|
||||
test("empty result when no matches", async () => {
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"list",
|
||||
"nonexistent/prefix/",
|
||||
"@test/nonexistent/prefix/",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
@@ -720,13 +770,13 @@ describe("var tag", () => {
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable without tags
|
||||
await runCli("var", "set", "x", hash);
|
||||
await runCli("var", "set", "@test/x", hash);
|
||||
|
||||
// Add tag
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"x",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"env:prod",
|
||||
@@ -744,13 +794,13 @@ describe("var tag", () => {
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable with tag
|
||||
await runCli("var", "set", "x", hash, "--tag", "env:dev");
|
||||
await runCli("var", "set", "@test/x", hash, "--tag", "env:dev");
|
||||
|
||||
// Update tag
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"x",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"env:prod",
|
||||
@@ -768,13 +818,13 @@ describe("var tag", () => {
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable without labels
|
||||
await runCli("var", "set", "x", hash);
|
||||
await runCli("var", "set", "@test/x", hash);
|
||||
|
||||
// Add label
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"x",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"stable",
|
||||
@@ -795,7 +845,7 @@ describe("var tag", () => {
|
||||
await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"x",
|
||||
"@test/x",
|
||||
hash,
|
||||
"--tag",
|
||||
"env:prod",
|
||||
@@ -807,7 +857,7 @@ describe("var tag", () => {
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"x",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
":env",
|
||||
@@ -825,13 +875,22 @@ describe("var tag", () => {
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable with labels
|
||||
await runCli("var", "set", "x", hash, "--tag", "stable", "--tag", "beta");
|
||||
await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"@test/x",
|
||||
hash,
|
||||
"--tag",
|
||||
"stable",
|
||||
"--tag",
|
||||
"beta",
|
||||
);
|
||||
|
||||
// Delete label
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"x",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
":stable",
|
||||
@@ -849,13 +908,13 @@ describe("var tag", () => {
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable with tags and labels
|
||||
await runCli("var", "set", "x", hash, "--tag", "a:1", "--tag", "b");
|
||||
await runCli("var", "set", "@test/x", hash, "--tag", "a:1", "--tag", "b");
|
||||
|
||||
// Mixed operations
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"x",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"c:3",
|
||||
@@ -876,13 +935,13 @@ describe("var tag", () => {
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable with tag
|
||||
await runCli("var", "set", "x", hash, "--tag", "env:prod");
|
||||
await runCli("var", "set", "@test/x", hash, "--tag", "env:prod");
|
||||
|
||||
// Try to add same name as label
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"x",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"env",
|
||||
@@ -899,7 +958,7 @@ describe("var tag", () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"nonexistent",
|
||||
"@test/nonexistent",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"env:prod",
|
||||
@@ -907,12 +966,17 @@ describe("var tag", () => {
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain(
|
||||
`Error: Variable not found: name=nonexistent, schema=${typeHash}`,
|
||||
`Error: Variable not found: name=@test/nonexistent, schema=${typeHash}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("error when --schema missing", async () => {
|
||||
const { stderr, exitCode } = await runCli("var", "tag", "x", "env:prod");
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@test/x",
|
||||
"env:prod",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain(
|
||||
@@ -927,7 +991,7 @@ describe("var tag", () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"x",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
);
|
||||
@@ -945,13 +1009,13 @@ describe("global options", () => {
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
await runCli("var", "set", "x", hash);
|
||||
await runCli("var", "set", "@test/x", hash);
|
||||
|
||||
const { stdout } = await runCli(
|
||||
"--json",
|
||||
"var",
|
||||
"get",
|
||||
"x",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
);
|
||||
@@ -981,7 +1045,7 @@ describe("global options", () => {
|
||||
varDbPath,
|
||||
"var",
|
||||
"set",
|
||||
"x",
|
||||
"@test/x",
|
||||
hash,
|
||||
],
|
||||
{
|
||||
@@ -1012,7 +1076,7 @@ describe("global options", () => {
|
||||
customDbPath,
|
||||
"var",
|
||||
"set",
|
||||
"x",
|
||||
"@test/x",
|
||||
hash,
|
||||
],
|
||||
{
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
"composite": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,47 +1,17 @@
|
||||
# @uncaged/json-cas
|
||||
# @ocas/core
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
|
||||
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
|
||||
- `openStore()` is now async and auto-bootstraps
|
||||
|
||||
### New Features
|
||||
|
||||
- 18 `@output/*` schemas registered at bootstrap
|
||||
- `list --type <hash-or-alias>` command (replaces `schema list`)
|
||||
- `verify` now checks both hash integrity and schema validation
|
||||
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
|
||||
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
|
||||
- Default LiquidJS templates for all output schemas
|
||||
- Pipe composition: any command output can be piped to `render -p`
|
||||
|
||||
## 0.5.3
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feat: add oneOf support to meta-schema validation
|
||||
- Fix TypeScript LSP errors: tsconfig `noEmit`, `exactOptionalPropertyTypes` conditional spread, `schemaHash` scope.
|
||||
|
||||
Added `oneOf` to `ALLOWED_SCHEMA_KEYS` and corresponding validation logic
|
||||
in `isValidSchema`. This enables workflow frontmatter schemas that use
|
||||
`oneOf` discriminated unions for multi-exit role definitions.
|
||||
## 0.1.1
|
||||
|
||||
## 0.3.0
|
||||
### Patch Changes
|
||||
|
||||
### Minor Changes
|
||||
- Internal improvements for v0.1.1 release.
|
||||
|
||||
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
|
||||
## 0.1.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
|
||||
|
||||
## 0.1.3
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ocas/core",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -23,5 +23,14 @@
|
||||
"cborg": "^4.2.3",
|
||||
"liquidjs": "^10.27.0",
|
||||
"xxhash-wasm": "^1.1.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/ocas.git",
|
||||
"directory": "packages/core"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/core",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ const OUTPUT_ALIASES = [
|
||||
"@ocas/output/var-tag",
|
||||
"@ocas/output/var-list",
|
||||
"@ocas/output/var-history",
|
||||
"@ocas/output/var-rollback",
|
||||
"@ocas/output/template-set",
|
||||
"@ocas/output/template-get",
|
||||
"@ocas/output/template-list",
|
||||
@@ -34,11 +33,11 @@ const OUTPUT_ALIASES = [
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bootstrap - Built-in Schemas", () => {
|
||||
test("should return map of 31 built-in schema aliases to hashes", async () => {
|
||||
test("should return map of 30 built-in schema aliases to hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
// Should return object with 9 primitive + 22 output aliases = 31
|
||||
// Should return object with 9 primitive + 21 output aliases = 30
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/schema");
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/string");
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/number");
|
||||
@@ -53,7 +52,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
expect(builtinSchemas).toHaveProperty(alias);
|
||||
}
|
||||
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(31);
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(30);
|
||||
|
||||
// All values should be valid hashes
|
||||
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
||||
@@ -329,13 +328,13 @@ describe("bootstrap - meta and schemas indexes (D1)", () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const metaHash = aliases["@ocas/schema"];
|
||||
expect(store.listMeta()).toContain(metaHash as string);
|
||||
expect(store.listMeta().map((e) => e.hash)).toContain(metaHash as string);
|
||||
});
|
||||
|
||||
test("listSchemas contains meta-schema and all built-in schemas", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const schemas = store.listSchemas();
|
||||
const schemas = store.listSchemas().map((e) => e.hash);
|
||||
|
||||
for (const [, hash] of Object.entries(aliases)) {
|
||||
expect(schemas).toContain(hash);
|
||||
|
||||
@@ -169,7 +169,15 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
"@ocas/output/list",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", format: "ocas_ref" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
},
|
||||
required: ["hash", "created", "updated"],
|
||||
},
|
||||
title: "ocas list result",
|
||||
},
|
||||
],
|
||||
@@ -177,7 +185,15 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
"@ocas/output/list-meta",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", format: "ocas_ref" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
},
|
||||
required: ["hash", "created", "updated"],
|
||||
},
|
||||
title: "ocas list-meta result",
|
||||
},
|
||||
],
|
||||
@@ -185,7 +201,15 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
"@ocas/output/list-schema",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", format: "ocas_ref" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
},
|
||||
required: ["hash", "created", "updated"],
|
||||
},
|
||||
title: "ocas list-schema result",
|
||||
},
|
||||
],
|
||||
@@ -244,14 +268,6 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
title: "ocas var history result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ocas/output/var-rollback",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ocas var rollback result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ocas/output/template-set",
|
||||
{
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("GC - Variable Model Refactoring", () => {
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
varStore.set("config", hashRef);
|
||||
varStore.set("@test/config", hashRef);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
@@ -66,8 +66,8 @@ describe("GC - Variable Model Refactoring", () => {
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
varStore.set("config", hashA);
|
||||
varStore.set("config", hashB);
|
||||
varStore.set("@test/config", hashA);
|
||||
varStore.set("@test/config", hashB);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
@@ -90,8 +90,8 @@ describe("GC - Variable Model Refactoring", () => {
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
varStore.set("config", hashRef);
|
||||
varStore.remove("config", schemaHash);
|
||||
varStore.set("@test/config", hashRef);
|
||||
varStore.remove("@test/config", schemaHash);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
@@ -117,9 +117,9 @@ describe("GC - Variable Model Refactoring", () => {
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
varStore.set("uwf.thread", hash1);
|
||||
varStore.set("uwf.workflow", hash2);
|
||||
varStore.set("app.config", hash3);
|
||||
varStore.set("@test/uwf.thread", hash1);
|
||||
varStore.set("@test/uwf.workflow", hash2);
|
||||
varStore.set("@test/app.config", hash3);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
@@ -151,9 +151,9 @@ describe("GC - Variable Model Refactoring", () => {
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
|
||||
// Create variables
|
||||
varStore.set("var1", hashA1);
|
||||
varStore.set("var2", hashA2);
|
||||
varStore.set("var3", hashB);
|
||||
varStore.set("@test/var1", hashA1);
|
||||
varStore.set("@test/var2", hashA2);
|
||||
varStore.set("@test/var3", hashB);
|
||||
|
||||
// First GC: orphans removed
|
||||
let stats = gc(store, varStore);
|
||||
@@ -165,7 +165,7 @@ describe("GC - Variable Model Refactoring", () => {
|
||||
expect(stats.scanned).toBe(3);
|
||||
|
||||
// Delete one variable
|
||||
varStore.remove("var2", schemaAHash);
|
||||
varStore.remove("@test/var2", schemaAHash);
|
||||
|
||||
// Second GC: hashA2 removed
|
||||
stats = gc(store, varStore);
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface GcStats {
|
||||
* - Schema preservation: schemas of reachable nodes are also marked
|
||||
*/
|
||||
export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
// Get all variables (no filters → global)
|
||||
// Get all variables (no filters → global). Omit `limit` so the full
|
||||
// variable set is returned for use as gc roots.
|
||||
const variables = varStore.list();
|
||||
const scanned = variables.length;
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ describe("createMemoryStore – has", () => {
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
const h3 = await store.put(typeHash, { a: 3 });
|
||||
|
||||
const all = store.listByType(typeHash);
|
||||
const all = store.listByType(typeHash).map((e) => e.hash);
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all).toContain(h1);
|
||||
expect(all).toContain(h2);
|
||||
@@ -179,7 +179,7 @@ describe("createMemoryStore – listByType", () => {
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
await store.put(otherType, { b: 1 });
|
||||
|
||||
const byType = store.listByType(typeHash);
|
||||
const byType = store.listByType(typeHash).map((e) => e.hash);
|
||||
expect(byType).toHaveLength(2);
|
||||
expect(byType).toContain(h1);
|
||||
expect(byType).toContain(h2);
|
||||
@@ -192,7 +192,7 @@ describe("createMemoryStore – listByType", () => {
|
||||
const h1 = await store.put(typeHash, { n: 1 });
|
||||
await store.put(typeHash, { n: 1 });
|
||||
|
||||
expect(store.listByType(typeHash)).toEqual([h1]);
|
||||
expect(store.listByType(typeHash).map((e) => e.hash)).toEqual([h1]);
|
||||
});
|
||||
|
||||
test("bootstrap node is listed under its self type", async () => {
|
||||
@@ -201,7 +201,7 @@ describe("createMemoryStore – listByType", () => {
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
// All built-in schemas should be typed by the meta-schema
|
||||
const allTypedByMeta = store.listByType(hash);
|
||||
const allTypedByMeta = store.listByType(hash).map((e) => e.hash);
|
||||
expect(allTypedByMeta).toContain(hash); // meta-schema itself
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@ocas/string"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@ocas/number"] ?? "");
|
||||
@@ -264,7 +264,7 @@ describe("bootstrap", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a map with 31 built-in schema aliases", async () => {
|
||||
test("returns a map with 30 built-in schema aliases", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("bootstrap", () => {
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(31);
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(30);
|
||||
});
|
||||
|
||||
test("meta-schema node is stored and retrievable", async () => {
|
||||
@@ -321,7 +321,7 @@ describe("bootstrap", () => {
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 22 outputs)
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
|
||||
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 21 outputs)
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ export { cborEncode } from "./cbor.js";
|
||||
export { type GcStats, gc } from "./gc.js";
|
||||
export { computeHash, computeSelfHash } from "./hash.js";
|
||||
export { renderWithTemplate } from "./liquid-render.js";
|
||||
export { applyListOptions, casListEntry } from "./list-utils.js";
|
||||
export { registerOutputTemplates } from "./output-templates.js";
|
||||
export {
|
||||
type RenderOptions,
|
||||
@@ -22,7 +23,14 @@ export {
|
||||
walk,
|
||||
} from "./schema.js";
|
||||
export { createMemoryStore } from "./store.js";
|
||||
export type { CasNode, Hash, Store } from "./types.js";
|
||||
export type {
|
||||
CasNode,
|
||||
Hash,
|
||||
ListEntry,
|
||||
ListOptions,
|
||||
ListSort,
|
||||
Store,
|
||||
} from "./types.js";
|
||||
export type { Variable } from "./variable.js";
|
||||
export {
|
||||
CasNodeNotFoundError,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
const HASH_RE = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||
|
||||
async function putN(
|
||||
store: ReturnType<typeof createMemoryStore>,
|
||||
type: string,
|
||||
n: number,
|
||||
delayMs = 2,
|
||||
): Promise<string[]> {
|
||||
const hashes: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
hashes.push(await store.put(type, { i }));
|
||||
if (delayMs > 0 && i < n - 1) {
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
describe("listByType - pagination + sort + timestamps", () => {
|
||||
test("A1. returns objects with hash/created/updated", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
|
||||
const list = store.listByType(m);
|
||||
for (const e of list) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
expect(typeof e.updated).toBe("number");
|
||||
expect(e.created).toBe(e.updated);
|
||||
}
|
||||
});
|
||||
|
||||
test("A2. default sort is created ASC", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
const list = store.listByType(m);
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect((list[i] as { created: number }).created).toBeGreaterThanOrEqual(
|
||||
(list[i - 1] as { created: number }).created,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("A3. desc:true reverses order", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
const list = store.listByType(m, { desc: true });
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect((list[i] as { created: number }).created).toBeLessThanOrEqual(
|
||||
(list[i - 1] as { created: number }).created,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("A4. sort: 'updated' is equivalent to 'created' for CAS nodes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
const a = store.listByType(m, { sort: "created" });
|
||||
const b = store.listByType(m, { sort: "updated" });
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test("A5. limit truncates", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5, 0);
|
||||
expect(store.listByType(m, { limit: 2 })).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("A6. offset skips", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5);
|
||||
|
||||
const all = store.listByType(m);
|
||||
const skip = store.listByType(m, { offset: 2, limit: 10 });
|
||||
expect(skip).toHaveLength(all.length - 2);
|
||||
expect(skip[0]).toEqual(all[2] as (typeof all)[number]);
|
||||
});
|
||||
|
||||
test("A7. limit:0 returns empty array", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
expect(store.listByType(m, { limit: 0 })).toEqual([]);
|
||||
});
|
||||
|
||||
test("A8. offset past end returns empty array", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
expect(store.listByType(m, { offset: 100 })).toEqual([]);
|
||||
});
|
||||
|
||||
test("A9. core has no default limit (returns all)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 150, 0);
|
||||
// No CLI-layer cap; with 150 nodes of type m (plus m itself which is
|
||||
// self-typed), the full set is returned.
|
||||
expect(store.listByType(m)).toHaveLength(151);
|
||||
});
|
||||
|
||||
test("A10. desc + offset + limit combined", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5, 15);
|
||||
const all = store.listByType(m);
|
||||
const got = store.listByType(m, { desc: true, offset: 1, limit: 2 });
|
||||
expect(got).toHaveLength(2);
|
||||
// desc order is reverse of `all`; offset 1 + limit 2 → all[n-2], all[n-3]
|
||||
const n = all.length;
|
||||
expect(got[0]?.hash).toBe(all[n - 2]?.hash as string);
|
||||
expect(got[1]?.hash).toBe(all[n - 3]?.hash as string);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMeta / listSchemas - pagination", () => {
|
||||
test("B1. listMeta returns {hash,created,updated}", async () => {
|
||||
const store = createMemoryStore();
|
||||
const h = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const list = store.listMeta();
|
||||
expect(list).toHaveLength(1);
|
||||
const e = list[0] as { hash: string; created: number; updated: number };
|
||||
expect(e.hash).toBe(h);
|
||||
expect(typeof e.created).toBe("number");
|
||||
expect(typeof e.updated).toBe("number");
|
||||
});
|
||||
|
||||
test("B2. listMeta has no default limit (returns all)", async () => {
|
||||
const store = createMemoryStore();
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await store[BOOTSTRAP_STORE]({ type: "object", i });
|
||||
}
|
||||
expect(store.listMeta()).toHaveLength(150);
|
||||
});
|
||||
|
||||
test("B3. listMeta limit/offset/desc", async () => {
|
||||
const store = createMemoryStore();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await store[BOOTSTRAP_STORE]({ type: "object", i });
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
expect(store.listMeta({ limit: 2 })).toHaveLength(2);
|
||||
const all = store.listMeta();
|
||||
const desc = store.listMeta({ desc: true });
|
||||
expect(desc[0]).toEqual(all[all.length - 1] as (typeof all)[number]);
|
||||
});
|
||||
|
||||
test("B4. listSchemas returns objects, supports limit", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await store.put(m, { type: "string" });
|
||||
await store.put(m, { type: "number" });
|
||||
|
||||
const list = store.listSchemas();
|
||||
for (const e of list) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
}
|
||||
expect(store.listSchemas({ limit: 1 })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Determinism / edge cases", () => {
|
||||
test("I1. same-ms timestamps yield deterministic ordering across calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
// No delay → likely same millisecond
|
||||
await putN(store, m, 5, 0);
|
||||
const a = store.listByType(m);
|
||||
const b = store.listByType(m);
|
||||
expect(b).toEqual(a);
|
||||
});
|
||||
|
||||
test("I2. empty store returns []", () => {
|
||||
const store = createMemoryStore();
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
expect(store.listMeta()).toEqual([]);
|
||||
expect(store.listSchemas()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Hash, ListEntry, ListOptions } from "./types.js";
|
||||
|
||||
/**
|
||||
* Apply sort/desc/offset/limit to an array of `ListEntry` records.
|
||||
* Default sort is by `created` ascending. If `limit` is omitted, all entries
|
||||
* (after offset) are returned.
|
||||
*
|
||||
* Tiebreaker is the entry hash (lexicographic ascending) so ordering remains
|
||||
* deterministic for entries sharing the same timestamp.
|
||||
*/
|
||||
export function applyListOptions(
|
||||
entries: ListEntry[],
|
||||
options?: ListOptions,
|
||||
): ListEntry[] {
|
||||
const sort = options?.sort ?? "created";
|
||||
const desc = options?.desc ?? false;
|
||||
const limit = options?.limit;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
const sorted = [...entries].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;
|
||||
// Hash tiebreaker — stable across calls
|
||||
if (a.hash === b.hash) return 0;
|
||||
return desc ? (a.hash < b.hash ? 1 : -1) : a.hash < b.hash ? -1 : 1;
|
||||
});
|
||||
|
||||
if (limit !== undefined) {
|
||||
if (limit <= 0) return [];
|
||||
return sorted.slice(offset, offset + limit);
|
||||
}
|
||||
return sorted.slice(offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `ListEntry` for a CAS node from its hash and timestamp.
|
||||
* For immutable CAS nodes `created === updated === timestamp`.
|
||||
*/
|
||||
export function casListEntry(hash: Hash, timestamp: number): ListEntry {
|
||||
return { hash, created: timestamp, updated: timestamp };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { CasNode, Hash } from "./types.js";
|
||||
import type { CasNode, Hash, ListEntry, ListOptions } from "./types.js";
|
||||
|
||||
/** In-memory store wrapper used by schema validation tests. */
|
||||
export class MemStore implements BootstrapCapableStore {
|
||||
@@ -23,20 +23,20 @@ export class MemStore implements BootstrapCapableStore {
|
||||
return this.#inner.has(hash);
|
||||
}
|
||||
|
||||
listByType(typeHash: Hash): Hash[] {
|
||||
return this.#inner.listByType(typeHash);
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
|
||||
return this.#inner.listByType(typeHash, options);
|
||||
}
|
||||
|
||||
listAll(): Hash[] {
|
||||
return this.#inner.listAll();
|
||||
}
|
||||
|
||||
listMeta(): Hash[] {
|
||||
return this.#inner.listMeta();
|
||||
listMeta(options?: ListOptions): ListEntry[] {
|
||||
return this.#inner.listMeta(options);
|
||||
}
|
||||
|
||||
listSchemas(): Hash[] {
|
||||
return this.#inner.listSchemas();
|
||||
listSchemas(options?: ListOptions): ListEntry[] {
|
||||
return this.#inner.listSchemas(options);
|
||||
}
|
||||
|
||||
delete(hash: Hash): void {
|
||||
|
||||
@@ -24,7 +24,6 @@ const OUTPUT_ALIASES = [
|
||||
"@ocas/output/var-tag",
|
||||
"@ocas/output/var-list",
|
||||
"@ocas/output/var-history",
|
||||
"@ocas/output/var-rollback",
|
||||
"@ocas/output/template-set",
|
||||
"@ocas/output/template-get",
|
||||
"@ocas/output/template-list",
|
||||
@@ -50,7 +49,7 @@ describe("registerOutputTemplates", () => {
|
||||
|
||||
const registered = await registerOutputTemplates(store, varStore);
|
||||
|
||||
expect(Object.keys(registered)).toHaveLength(20);
|
||||
expect(Object.keys(registered)).toHaveLength(19);
|
||||
|
||||
for (const alias of OUTPUT_ALIASES) {
|
||||
expect(registered).toHaveProperty(alias);
|
||||
|
||||
@@ -40,10 +40,6 @@ const DEFAULT_TEMPLATES: ReadonlyArray<
|
||||
"@ocas/output/var-history",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\n{% for v in payload.values %}{{ forloop.index0 }}: {{ v }}\n{% endfor %}",
|
||||
],
|
||||
[
|
||||
"@ocas/output/var-rollback",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||
],
|
||||
[
|
||||
"@ocas/output/template-set",
|
||||
"schemaHash: {{ payload.schemaHash }}\ncontentHash: {{ payload.contentHash }}",
|
||||
|
||||
@@ -13,8 +13,8 @@ describe("createMemoryStore – meta and schema indexes", () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
|
||||
expect(store.listMeta()).toContain(hash);
|
||||
expect(store.listSchemas()).toContain(hash);
|
||||
expect(store.listMeta().map((e) => e.hash)).toContain(hash);
|
||||
expect(store.listSchemas().map((e) => e.hash)).toContain(hash);
|
||||
});
|
||||
|
||||
test("B3. regular put does not add hash to metaSet", async () => {
|
||||
@@ -22,8 +22,8 @@ describe("createMemoryStore – meta and schema indexes", () => {
|
||||
const metaHash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const schemaHash = await store.put(metaHash, { type: "string" });
|
||||
|
||||
expect(store.listMeta()).not.toContain(schemaHash);
|
||||
expect(store.listMeta()).toContain(metaHash);
|
||||
expect(store.listMeta().map((e) => e.hash)).not.toContain(schemaHash);
|
||||
expect(store.listMeta().map((e) => e.hash)).toContain(metaHash);
|
||||
});
|
||||
|
||||
test("B4. schema typed by meta-schema appears in listSchemas", async () => {
|
||||
@@ -31,11 +31,11 @@ describe("createMemoryStore – meta and schema indexes", () => {
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const s = await store.put(m, { type: "string" });
|
||||
|
||||
const schemas = store.listSchemas();
|
||||
const schemas = store.listSchemas().map((e) => e.hash);
|
||||
expect(schemas).toContain(m);
|
||||
expect(schemas).toContain(s);
|
||||
|
||||
const meta = store.listMeta();
|
||||
const meta = store.listMeta().map((e) => e.hash);
|
||||
expect(meta).toContain(m);
|
||||
expect(meta).not.toContain(s);
|
||||
});
|
||||
@@ -47,12 +47,12 @@ describe("createMemoryStore – meta and schema indexes", () => {
|
||||
const s1 = await store.put(m1, { type: "string" });
|
||||
const s2 = await store.put(m2, { type: "number" });
|
||||
|
||||
const meta = store.listMeta();
|
||||
const meta = store.listMeta().map((e) => e.hash);
|
||||
expect(meta).toContain(m1);
|
||||
expect(meta).toContain(m2);
|
||||
expect(meta).toHaveLength(2);
|
||||
|
||||
const schemas = store.listSchemas();
|
||||
const schemas = store.listSchemas().map((e) => e.hash);
|
||||
expect(schemas).toContain(m1);
|
||||
expect(schemas).toContain(m2);
|
||||
expect(schemas).toContain(s1);
|
||||
@@ -66,7 +66,7 @@ describe("createMemoryStore – meta and schema indexes", () => {
|
||||
const h2 = await store[BOOTSTRAP_STORE](payload);
|
||||
expect(h1).toBe(h2);
|
||||
|
||||
const meta = store.listMeta();
|
||||
const meta = store.listMeta().map((e) => e.hash);
|
||||
const occurrences = meta.filter((h) => h === h1).length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
@@ -76,14 +76,14 @@ describe("createMemoryStore – meta and schema indexes", () => {
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const s = await store.put(m, { type: "string" });
|
||||
|
||||
expect(store.listMeta()).toContain(m);
|
||||
expect(store.listSchemas()).toContain(s);
|
||||
expect(store.listMeta().map((e) => e.hash)).toContain(m);
|
||||
expect(store.listSchemas().map((e) => e.hash)).toContain(s);
|
||||
|
||||
store.delete(m);
|
||||
|
||||
expect(store.listMeta()).not.toContain(m);
|
||||
expect(store.listMeta().map((e) => e.hash)).not.toContain(m);
|
||||
// schemas typed by deleted meta no longer surface
|
||||
expect(store.listSchemas()).not.toContain(s);
|
||||
expect(store.listSchemas()).not.toContain(m);
|
||||
expect(store.listSchemas().map((e) => e.hash)).not.toContain(s);
|
||||
expect(store.listSchemas().map((e) => e.hash)).not.toContain(m);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
type BootstrapCapableStore,
|
||||
} from "./bootstrap-capable.js";
|
||||
import { computeHash, computeSelfHash } from "./hash.js";
|
||||
import type { CasNode, Hash } from "./types.js";
|
||||
import { applyListOptions, casListEntry } from "./list-utils.js";
|
||||
import type { CasNode, Hash, ListEntry, ListOptions } from "./types.js";
|
||||
|
||||
export function createMemoryStore(): BootstrapCapableStore {
|
||||
const data = new Map<Hash, CasNode>();
|
||||
@@ -29,6 +30,15 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
return hash;
|
||||
}
|
||||
|
||||
function entriesForHashes(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;
|
||||
}
|
||||
|
||||
const store: BootstrapCapableStore = {
|
||||
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
@@ -49,20 +59,21 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
return data.has(hash);
|
||||
},
|
||||
|
||||
listByType(typeHash: Hash): Hash[] {
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
|
||||
const set = byType.get(typeHash);
|
||||
return set ? [...set] : [];
|
||||
if (!set) return [];
|
||||
return applyListOptions(entriesForHashes(set), options);
|
||||
},
|
||||
|
||||
listAll(): Hash[] {
|
||||
return Array.from(data.keys());
|
||||
},
|
||||
|
||||
listMeta(): Hash[] {
|
||||
return Array.from(metaSet);
|
||||
listMeta(options?: ListOptions): ListEntry[] {
|
||||
return applyListOptions(entriesForHashes(metaSet), options);
|
||||
},
|
||||
|
||||
listSchemas(): Hash[] {
|
||||
listSchemas(options?: ListOptions): ListEntry[] {
|
||||
const result = new Set<Hash>();
|
||||
for (const meta of metaSet) {
|
||||
result.add(meta);
|
||||
@@ -71,7 +82,7 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
for (const h of set) result.add(h);
|
||||
}
|
||||
}
|
||||
return Array.from(result);
|
||||
return applyListOptions(entriesForHashes(result), options);
|
||||
},
|
||||
|
||||
delete(hash: Hash): void {
|
||||
|
||||
@@ -15,6 +15,35 @@ export type CasNode<T = unknown> = {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort key for list operations.
|
||||
* - "created": ordering by node creation timestamp (default).
|
||||
* - "updated": ordering by mutation timestamp; for immutable CAS nodes this
|
||||
* is identical to "created".
|
||||
*/
|
||||
export type ListSort = "created" | "updated";
|
||||
|
||||
/**
|
||||
* Common options shared by list operations on the CAS store.
|
||||
*/
|
||||
export type ListOptions = {
|
||||
sort?: ListSort;
|
||||
desc?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One entry in the result of a list operation: a hash plus its
|
||||
* creation/mutation timestamps. For immutable CAS nodes
|
||||
* `created === updated === node.timestamp`.
|
||||
*/
|
||||
export type ListEntry = {
|
||||
hash: Hash;
|
||||
created: number;
|
||||
updated: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Content-addressable store interface.
|
||||
* Self-referencing nodes are created only via bootstrap().
|
||||
@@ -23,9 +52,9 @@ export type Store = {
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash>;
|
||||
get(hash: Hash): CasNode | null;
|
||||
has(hash: Hash): boolean;
|
||||
listByType(typeHash: Hash): Hash[];
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[];
|
||||
listAll(): Hash[];
|
||||
listMeta(): Hash[];
|
||||
listSchemas(): Hash[];
|
||||
listMeta(options?: ListOptions): ListEntry[];
|
||||
listSchemas(options?: ListOptions): ListEntry[];
|
||||
delete(hash: Hash): void;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Hash } from "./types.js";
|
||||
import { createVariableStore, type VariableStore } from "./variable-store.js";
|
||||
|
||||
let dbDir: string;
|
||||
let dbPath: string;
|
||||
let casStore: ReturnType<typeof createMemoryStore>;
|
||||
let varStore: VariableStore;
|
||||
let stringHash: Hash;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbDir = mkdtempSync(join(tmpdir(), "ocas-var-pagination-"));
|
||||
dbPath = join(dbDir, "vars.db");
|
||||
casStore = createMemoryStore();
|
||||
const aliases = await bootstrap(casStore);
|
||||
stringHash = aliases["@ocas/string"] as Hash;
|
||||
varStore = createVariableStore(dbPath, casStore);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
varStore.close();
|
||||
rmSync(dbDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function setN(prefix: string, n: number, delayMs = 2): Promise<Hash[]> {
|
||||
const hashes: Hash[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const h = await casStore.put(stringHash, `${prefix}-${i}`);
|
||||
varStore.set(`@test/${prefix}-${i}`, h);
|
||||
hashes.push(h);
|
||||
if (delayMs > 0 && i < n - 1) {
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
describe("VariableStore.list - pagination + sort", () => {
|
||||
test("D1. default sort = created ASC", async () => {
|
||||
await setN("v", 3);
|
||||
const list = varStore.list({ namePrefix: "@test/v-" });
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect((list[i] as { created: number }).created).toBeGreaterThanOrEqual(
|
||||
(list[i - 1] as { created: number }).created,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("D2. sort: 'updated' differs after re-set", async () => {
|
||||
await setN("u", 3);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
// Re-set u-0 with a NEW value so updated changes
|
||||
const newHash = await casStore.put(stringHash, "u-0-new");
|
||||
varStore.set("@test/u-0", newHash);
|
||||
|
||||
const byUpdated = varStore.list({
|
||||
namePrefix: "@test/u-",
|
||||
sort: "updated",
|
||||
});
|
||||
// u-0 should be last when sorted updated ASC
|
||||
const last = byUpdated[byUpdated.length - 1] as { name: string };
|
||||
expect(last.name).toBe("@test/u-0");
|
||||
});
|
||||
|
||||
test("D3. desc reverses both sort modes", async () => {
|
||||
await setN("d", 3);
|
||||
const asc = varStore.list({ namePrefix: "@test/d-" });
|
||||
const desc = varStore.list({ namePrefix: "@test/d-", desc: true });
|
||||
expect(desc[0]).toEqual(asc[asc.length - 1] as (typeof asc)[number]);
|
||||
});
|
||||
|
||||
test("D4. limit/offset honored", async () => {
|
||||
await setN("p", 5);
|
||||
expect(varStore.list({ namePrefix: "@test/p-", limit: 2 })).toHaveLength(2);
|
||||
expect(
|
||||
varStore.list({ namePrefix: "@test/p-", offset: 2, limit: 10 }),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("D5. core has no default limit (returns all)", async () => {
|
||||
await setN("big", 105, 0);
|
||||
const list = varStore.list({ namePrefix: "@test/big-" });
|
||||
expect(list).toHaveLength(105);
|
||||
});
|
||||
|
||||
test("D6. pagination applied AFTER namePrefix/schema filters", async () => {
|
||||
await setN("filt", 5);
|
||||
const list = varStore.list({
|
||||
namePrefix: "@test/filt-",
|
||||
schema: stringHash,
|
||||
limit: 2,
|
||||
});
|
||||
expect(list).toHaveLength(2);
|
||||
for (const v of list) {
|
||||
expect((v as { name: string }).name.startsWith("@test/filt-")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("limit: 0 returns empty array", async () => {
|
||||
await setN("z", 3, 0);
|
||||
expect(varStore.list({ namePrefix: "@test/z-", limit: 0 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { Hash, ListSort, Store } from "./types.js";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
/**
|
||||
@@ -136,49 +136,48 @@ export class VariableStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate variable name format
|
||||
* @ is allowed at the start of the first segment (system-reserved)
|
||||
* Validate variable name format.
|
||||
* All names must follow @scope/name pattern:
|
||||
* - scope: @[a-zA-Z][a-zA-Z0-9]* (e.g. @myapp, @ocas)
|
||||
* - name: one or more segments of [a-zA-Z0-9._-]+ separated by /
|
||||
* Examples: @myapp/config, @todo/schema, @ocas/schema
|
||||
*/
|
||||
private validateName(name: string): void {
|
||||
// Rule 1: Cannot be empty
|
||||
if (name === "") {
|
||||
throw new InvalidVariableNameError(name, "Name cannot be empty");
|
||||
}
|
||||
|
||||
// Rule 2: No leading slash
|
||||
if (name.startsWith("/")) {
|
||||
// Must match @scope/name where scope starts with a letter
|
||||
const match = name.match(/^@([a-zA-Z][a-zA-Z0-9]*)\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot start with leading slash",
|
||||
"Name must follow @scope/name format (e.g. @myapp/config)",
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 3: No trailing slash
|
||||
if (name.endsWith("/")) {
|
||||
const rest = match[2] as string;
|
||||
|
||||
// Validate remaining segments
|
||||
if (rest.endsWith("/")) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot end with trailing slash",
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment)
|
||||
const segments = name.split("/");
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i] as string;
|
||||
const segments = rest.split("/");
|
||||
for (const segment of segments) {
|
||||
if (segment === "") {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name contains empty segment (consecutive slashes //)",
|
||||
);
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
// First segment can start with @, all segments can contain [a-zA-Z0-9._-]
|
||||
const regex = i === 0 ? /^@?[a-zA-Z0-9._-]+$/ : /^[a-zA-Z0-9._-]+$/;
|
||||
if (!regex.test(segment)) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
`Segment "${segment}" contains invalid characters (only ${i === 0 ? "@, " : ""}a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -605,7 +604,9 @@ export class VariableStore {
|
||||
}
|
||||
|
||||
// Remove all schema variants for this name
|
||||
const variants = this.list({ exactName: name });
|
||||
const variants = this.list({
|
||||
exactName: name,
|
||||
});
|
||||
|
||||
if (variants.length === 0) {
|
||||
return [];
|
||||
@@ -629,6 +630,10 @@ export class VariableStore {
|
||||
schema?: Hash;
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
sort?: ListSort;
|
||||
desc?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Variable[] {
|
||||
// Validate mutually exclusive options
|
||||
if (options?.namePrefix !== undefined && options?.exactName !== undefined) {
|
||||
@@ -642,6 +647,12 @@ export class VariableStore {
|
||||
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 [];
|
||||
|
||||
// Build query with filters
|
||||
let query = `
|
||||
@@ -695,7 +706,18 @@ export class VariableStore {
|
||||
query += ` WHERE ${whereClauses.join(" AND ")}`;
|
||||
}
|
||||
|
||||
query += " ORDER BY v.created ASC";
|
||||
const sortColumn = sort === "updated" ? "v.updated" : "v.created";
|
||||
const direction = desc ? "DESC" : "ASC";
|
||||
// Tiebreaker: name ASC for stable ordering across same-ms timestamps
|
||||
query += ` ORDER BY ${sortColumn} ${direction}, v.name ASC`;
|
||||
if (limit !== undefined) {
|
||||
query += " LIMIT ? OFFSET ?";
|
||||
params.push(limit, offset);
|
||||
} else if (offset > 0) {
|
||||
// SQLite requires LIMIT when using OFFSET; use -1 to mean "no limit".
|
||||
query += " LIMIT -1 OFFSET ?";
|
||||
params.push(offset);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...params) as Array<{
|
||||
@@ -843,67 +865,6 @@ export class VariableStore {
|
||||
return rows.map((r) => r.value as Hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back a variable to the value currently at the given history position.
|
||||
* The value at `position` is moved to position 0 (current); positions in
|
||||
* [0, position) are shifted +1.
|
||||
*
|
||||
* Throws VariableNotFoundError if the variable does not exist or there is
|
||||
* no history entry at the given position.
|
||||
*/
|
||||
rollback(name: string, schema: Hash, position: number): Variable {
|
||||
if (!Number.isInteger(position) || position < 0) {
|
||||
throw new Error(
|
||||
`Invalid history position: ${position} (must be a non-negative integer)`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
if (position === 0) {
|
||||
// No-op rollback to current
|
||||
return existing;
|
||||
}
|
||||
|
||||
const targetRow = this.db
|
||||
.prepare(
|
||||
`SELECT value FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = ?`,
|
||||
)
|
||||
.get(name, schema, position) as { value: string } | undefined | null;
|
||||
|
||||
if (!targetRow) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const targetValue = targetRow.value as Hash;
|
||||
const now = Date.now();
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
try {
|
||||
const changed = this.recordHistory(name, schema, targetValue, now);
|
||||
if (changed) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE variables SET value = ?, updated = ? WHERE name = ? AND schema = ?`,
|
||||
)
|
||||
.run(targetValue, now, name, schema);
|
||||
}
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
const updated = this.get(name, schema);
|
||||
if (updated === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
|
||||
@@ -1,70 +1,19 @@
|
||||
# @uncaged/json-cas-fs
|
||||
# @ocas/fs
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
|
||||
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
|
||||
- `openStore()` is now async and auto-bootstraps
|
||||
|
||||
### New Features
|
||||
|
||||
- 18 `@output/*` schemas registered at bootstrap
|
||||
- `list --type <hash-or-alias>` command (replaces `schema list`)
|
||||
- `verify` now checks both hash integrity and schema validation
|
||||
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
|
||||
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
|
||||
- Default LiquidJS templates for all output schemas
|
||||
- Pipe composition: any command output can be piped to `render -p`
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.6.0
|
||||
- Updated dependencies:
|
||||
- @ocas/core@0.1.2
|
||||
|
||||
## 0.5.3
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feat: add oneOf support to meta-schema validation
|
||||
- Updated dependencies:
|
||||
- @ocas/core@0.1.1
|
||||
|
||||
Added `oneOf` to `ALLOWED_SCHEMA_KEYS` and corresponding validation logic
|
||||
in `isValidSchema`. This enables workflow frontmatter schemas that use
|
||||
`oneOf` discriminated unions for multi-exit role definitions.
|
||||
## 0.1.0
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.5.3
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.3.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.2.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.1.3
|
||||
Initial release as `@ocas/fs`. Filesystem-backed CAS store with SQLite indexing and auto-bootstrap.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ocas/fs",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -20,6 +20,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"cborg": "^4.2.3",
|
||||
"@ocas/core": "workspace:*"
|
||||
"@ocas/core": "0.1.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/ocas.git",
|
||||
"directory": "packages/fs"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/fs",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("createFsStore – has and list", () => {
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
const h3 = await store.put(typeHash, { a: 3 });
|
||||
|
||||
const all = store.listByType(typeHash);
|
||||
const all = store.listByType(typeHash).map((e) => e.hash);
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all).toContain(h1);
|
||||
expect(all).toContain(h2);
|
||||
@@ -213,7 +213,7 @@ describe("createFsStore – listByType", () => {
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
await store.put(otherType, { b: 1 });
|
||||
|
||||
const byType = store.listByType(typeHash);
|
||||
const byType = store.listByType(typeHash).map((e) => e.hash);
|
||||
expect(byType).toHaveLength(2);
|
||||
expect(byType).toContain(h1);
|
||||
expect(byType).toContain(h2);
|
||||
@@ -227,7 +227,7 @@ describe("createFsStore – listByType", () => {
|
||||
const h2 = await store1.put(typeHash, { x: 2 });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const byType = store2.listByType(typeHash);
|
||||
const byType = store2.listByType(typeHash).map((e) => e.hash);
|
||||
expect(byType).toHaveLength(2);
|
||||
expect(byType).toContain(h1);
|
||||
expect(byType).toContain(h2);
|
||||
@@ -241,7 +241,7 @@ describe("createFsStore – listByType", () => {
|
||||
await store1.put(typeHash, { n: 7 });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listByType(typeHash)).toEqual([hash]);
|
||||
expect(store2.listByType(typeHash).map((e) => e.hash)).toEqual([hash]);
|
||||
});
|
||||
|
||||
test("rebuilds _index from .bin files when index is missing", async () => {
|
||||
@@ -254,7 +254,7 @@ describe("createFsStore – listByType", () => {
|
||||
rmSync(join(dir, "_index"), { recursive: true, force: true });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listByType(typeHash)).toEqual([h1, h2]);
|
||||
expect(store2.listByType(typeHash).map((e) => e.hash)).toEqual([h1, h2]);
|
||||
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
|
||||
expect(readdirSync(join(dir, "_index"))).toContain(typeHash);
|
||||
});
|
||||
@@ -265,7 +265,7 @@ describe("createFsStore – listByType", () => {
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listByType(hash)).toContain(hash);
|
||||
expect(store2.listByType(hash).map((e) => e.hash)).toContain(hash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -474,7 +474,7 @@ describe("createFsStore – listMeta and listSchemas", () => {
|
||||
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "b" });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const meta = store2.listMeta();
|
||||
const meta = store2.listMeta().map((e) => e.hash);
|
||||
expect(meta).toContain(h1);
|
||||
expect(meta).toContain(h2);
|
||||
expect(meta).toHaveLength(2);
|
||||
@@ -492,13 +492,13 @@ describe("createFsStore – listMeta and listSchemas", () => {
|
||||
expect(existsSync(metaPath)).toBe(false);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listMeta()).toContain(h1);
|
||||
expect(store2.listMeta().map((e) => e.hash)).toContain(h1);
|
||||
expect(existsSync(metaPath)).toBe(true);
|
||||
const content = readFileSync(metaPath, "utf8");
|
||||
expect(content).toContain(h1);
|
||||
|
||||
// unrelated type hash not in meta
|
||||
expect(store2.listMeta()).not.toContain(t);
|
||||
expect(store2.listMeta().map((e) => e.hash)).not.toContain(t);
|
||||
});
|
||||
|
||||
test("C5. existing _meta is not overwritten", async () => {
|
||||
@@ -522,7 +522,7 @@ describe("createFsStore – listMeta and listSchemas", () => {
|
||||
const s2 = await store.put(m, { type: "number" });
|
||||
const s3 = await store.put(m, { type: "array" });
|
||||
|
||||
const schemas = store.listSchemas();
|
||||
const schemas = store.listSchemas().map((e) => e.hash);
|
||||
expect(schemas).toHaveLength(4);
|
||||
expect(schemas).toContain(m);
|
||||
expect(schemas).toContain(s1);
|
||||
@@ -543,8 +543,8 @@ describe("createFsStore – listMeta and listSchemas", () => {
|
||||
expect(content).toContain(h2);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listMeta()).not.toContain(h1);
|
||||
expect(store2.listMeta()).toContain(h2);
|
||||
expect(store2.listMeta().map((e) => e.hash)).not.toContain(h1);
|
||||
expect(store2.listMeta().map((e) => e.hash)).toContain(h2);
|
||||
});
|
||||
|
||||
test("C8. fresh store with no self-ref puts has empty listMeta", () => {
|
||||
|
||||
@@ -14,12 +14,16 @@ import type {
|
||||
BootstrapCapableStore,
|
||||
CasNode,
|
||||
Hash,
|
||||
ListEntry,
|
||||
ListOptions,
|
||||
VariableStore,
|
||||
} from "@ocas/core";
|
||||
|
||||
import {
|
||||
applyListOptions,
|
||||
BOOTSTRAP_STORE,
|
||||
bootstrap,
|
||||
casListEntry,
|
||||
cborEncode,
|
||||
computeHash,
|
||||
computeSelfHash,
|
||||
@@ -174,6 +178,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;
|
||||
}
|
||||
|
||||
export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
const data = new Map<Hash, CasNode>();
|
||||
loadDir(dir, data);
|
||||
@@ -237,19 +253,21 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
return data.has(hash);
|
||||
},
|
||||
|
||||
listByType(typeHash: Hash): Hash[] {
|
||||
return typeIndex.get(typeHash) ?? [];
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
|
||||
const list = typeIndex.get(typeHash);
|
||||
if (!list) return [];
|
||||
return applyListOptions(hashesToEntries(data, list), options);
|
||||
},
|
||||
|
||||
listAll(): Hash[] {
|
||||
return Array.from(data.keys());
|
||||
},
|
||||
|
||||
listMeta(): Hash[] {
|
||||
return Array.from(metaSet);
|
||||
listMeta(options?: ListOptions): ListEntry[] {
|
||||
return applyListOptions(hashesToEntries(data, metaSet), options);
|
||||
},
|
||||
|
||||
listSchemas(): Hash[] {
|
||||
listSchemas(options?: ListOptions): ListEntry[] {
|
||||
const result = new Set<Hash>();
|
||||
for (const meta of metaSet) {
|
||||
result.add(meta);
|
||||
@@ -258,7 +276,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
for (const h of list) result.add(h);
|
||||
}
|
||||
}
|
||||
return Array.from(result);
|
||||
return applyListOptions(hashesToEntries(data, result), options);
|
||||
},
|
||||
|
||||
delete(hash: Hash): void {
|
||||
|
||||
@@ -51,10 +51,6 @@ bunx changeset status
|
||||
|
||||
# --- Create release branch ---
|
||||
|
||||
echo ""
|
||||
read -rp "Proceed with release branch? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
# Get current version to name the branch
|
||||
CURRENT_VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
|
||||
info "Current version: $CURRENT_VERSION"
|
||||
@@ -75,6 +71,23 @@ info "New version: $NEW_VERSION"
|
||||
git branch -m "release/next" "release/$NEW_VERSION"
|
||||
info "Release branch: release/$NEW_VERSION"
|
||||
|
||||
# --- Clean up changesets on main ---
|
||||
|
||||
info "Removing consumed changesets from main..."
|
||||
git stash --include-untracked
|
||||
git checkout main
|
||||
# Delete the changeset files that were consumed
|
||||
for cs in $CHANGESETS; do
|
||||
rm -f "$cs"
|
||||
done
|
||||
git add -A
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
git commit -m "chore: remove changesets consumed by release/$NEW_VERSION"
|
||||
git push origin main
|
||||
fi
|
||||
git checkout "release/$NEW_VERSION"
|
||||
git stash pop || true
|
||||
|
||||
# --- Validate ---
|
||||
|
||||
echo ""
|
||||
@@ -105,5 +118,6 @@ echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Review changes: git diff main...HEAD"
|
||||
echo " 2. Fix issues if needed, commit to this branch"
|
||||
echo " 3. When ready: ./scripts/publish.sh"
|
||||
echo " 3. Prerelease: ./scripts/publish.sh --rc"
|
||||
echo " 4. Final release: ./scripts/publish.sh"
|
||||
echo ""
|
||||
|
||||
+119
-42
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
# OCAS Publish
|
||||
# Builds, publishes to npm, tags, and pushes.
|
||||
#
|
||||
# Usage: ./scripts/publish.sh
|
||||
# Usage: ./scripts/publish.sh # publish final release
|
||||
# ./scripts/publish.sh --rc # publish prerelease (rc tag)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - On a release/* branch (created by prepare-release.sh)
|
||||
@@ -21,6 +22,13 @@ info() { echo -e "${BOLD}${GREEN}✓${NC} $1"; }
|
||||
warn() { echo -e "${BOLD}${YELLOW}⚠${NC} $1"; }
|
||||
error() { echo -e "${BOLD}${RED}✗${NC} $1"; exit 1; }
|
||||
|
||||
# --- Parse flags ---
|
||||
|
||||
RC_MODE=false
|
||||
if [[ "${1:-}" == "--rc" ]]; then
|
||||
RC_MODE=true
|
||||
fi
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
|
||||
echo -e "\n${BOLD}OCAS Publish${NC}\n"
|
||||
@@ -32,14 +40,31 @@ BRANCH=$(git branch --show-current)
|
||||
# Clean working tree
|
||||
[[ -z "$(git status --porcelain)" ]] || error "Working tree is not clean. Commit changes first."
|
||||
|
||||
# Extract version
|
||||
VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
|
||||
info "Publishing version: $VERSION"
|
||||
# Extract base version from package.json
|
||||
BASE_VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
|
||||
|
||||
# No pending changesets (should have been consumed by prepare-release.sh)
|
||||
CHANGESETS=$(ls .changeset/*.md 2>/dev/null | grep -v README.md || true)
|
||||
if [[ -n "$CHANGESETS" ]]; then
|
||||
error "Pending changesets found. Run prepare-release.sh first."
|
||||
# Determine publish version and npm tag
|
||||
if [[ "$RC_MODE" == "true" ]]; then
|
||||
# Find next rc number
|
||||
EXISTING_RC=$(npm view "@ocas/core" versions --json 2>/dev/null | python3 -c "
|
||||
import json, sys, re
|
||||
versions = json.load(sys.stdin)
|
||||
if not isinstance(versions, list): versions = [versions]
|
||||
rc_nums = [int(m.group(1)) for v in versions if (m := re.match(r'^${BASE_VERSION}-rc\.(\d+)$', v))]
|
||||
print(max(rc_nums) + 1 if rc_nums else 1)
|
||||
" 2>/dev/null || echo 1)
|
||||
VERSION="${BASE_VERSION}-rc.${EXISTING_RC}"
|
||||
NPM_TAG="rc"
|
||||
info "Prerelease mode: $VERSION (npm tag: rc)"
|
||||
else
|
||||
VERSION="$BASE_VERSION"
|
||||
NPM_TAG="latest"
|
||||
|
||||
# No pending changesets (should have been consumed by prepare-release.sh)
|
||||
CHANGESETS=$(ls .changeset/*.md 2>/dev/null | grep -v README.md || true)
|
||||
if [[ -n "$CHANGESETS" ]]; then
|
||||
error "Pending changesets found. Run prepare-release.sh first."
|
||||
fi
|
||||
fi
|
||||
|
||||
# npm auth check
|
||||
@@ -47,6 +72,24 @@ npm whoami &>/dev/null || error "Not authenticated with npm. Run 'npm login' fir
|
||||
NPM_USER=$(npm whoami)
|
||||
info "npm user: $NPM_USER"
|
||||
|
||||
# --- Set version in all packages ---
|
||||
|
||||
info "Setting version: $VERSION"
|
||||
for pkg in core fs cli; do
|
||||
python3 -c "
|
||||
import json
|
||||
path = 'packages/$pkg/package.json'
|
||||
data = json.load(open(path))
|
||||
data['version'] = '$VERSION'
|
||||
# Fix workspace:* to actual version for publishing
|
||||
for dep in list(data.get('dependencies', {})):
|
||||
if data['dependencies'][dep] == 'workspace:*':
|
||||
data['dependencies'][dep] = '$VERSION'
|
||||
json.dump(data, open(path, 'w'), indent=2)
|
||||
open(path, 'a').write('\n')
|
||||
"
|
||||
done
|
||||
|
||||
# --- Final validation ---
|
||||
|
||||
info "Running final validation..."
|
||||
@@ -57,57 +100,91 @@ bun run build
|
||||
echo " → bun test"
|
||||
bun test || error "Tests failed! Fix before publishing."
|
||||
|
||||
# --- Confirm ---
|
||||
# --- Publish (order matters: core → fs → cli) ---
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Will publish:${NC}"
|
||||
for pkg in core fs cli; do
|
||||
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
|
||||
echo " $PKG_NAME@$VERSION"
|
||||
echo " $PKG_NAME@$VERSION (tag: $NPM_TAG)"
|
||||
done
|
||||
echo ""
|
||||
read -rp "Publish to npm? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
# --- Publish (order matters: core → fs → cli) ---
|
||||
|
||||
for pkg in core fs cli; do
|
||||
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
|
||||
echo ""
|
||||
info "Publishing $PKG_NAME@$VERSION..."
|
||||
(cd "packages/$pkg" && bun publish --access public)
|
||||
(cd "packages/$pkg" && bun publish --access public --tag "$NPM_TAG")
|
||||
info "$PKG_NAME@$VERSION published ✓"
|
||||
done
|
||||
|
||||
# --- Tag and push ---
|
||||
# --- Commit version changes ---
|
||||
|
||||
echo ""
|
||||
TAG="v$VERSION"
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
git push origin "$BRANCH"
|
||||
git push origin "$TAG"
|
||||
info "Tag $TAG pushed"
|
||||
git add -A
|
||||
if [[ -n "$(git diff --cached --name-only)" ]]; then
|
||||
git commit -m "chore(release): set version $VERSION"
|
||||
fi
|
||||
|
||||
# --- Merge back to main ---
|
||||
# --- For final release: tag, push, merge back ---
|
||||
|
||||
echo ""
|
||||
info "Merging release into main..."
|
||||
git checkout main
|
||||
git merge "$BRANCH" --no-ff -m "chore: merge release $TAG"
|
||||
git push origin main
|
||||
info "Merged to main"
|
||||
if [[ "$RC_MODE" == "false" ]]; then
|
||||
TAG="v$VERSION"
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
git push origin "$BRANCH" --tags
|
||||
git push github "$BRANCH" --tags 2>/dev/null || true
|
||||
info "Tag $TAG pushed"
|
||||
|
||||
# Clean up release branch
|
||||
git branch -d "$BRANCH"
|
||||
git push origin --delete "$BRANCH" 2>/dev/null || true
|
||||
info "Release branch cleaned up"
|
||||
# Merge back to main
|
||||
echo ""
|
||||
info "Merging release into main..."
|
||||
git checkout main
|
||||
git merge "$BRANCH" --no-ff -m "chore: merge release $TAG"
|
||||
|
||||
echo ""
|
||||
info "Release $TAG complete! 🎉"
|
||||
echo ""
|
||||
echo " Published:"
|
||||
for pkg in core fs cli; do
|
||||
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
|
||||
echo " https://www.npmjs.com/package/$PKG_NAME"
|
||||
done
|
||||
# Restore workspace:* on main
|
||||
for pkg in fs cli; do
|
||||
python3 -c "
|
||||
import json
|
||||
path = 'packages/$pkg/package.json'
|
||||
data = json.load(open(path))
|
||||
for dep in list(data.get('dependencies', {})):
|
||||
if dep.startswith('@ocas/'):
|
||||
data['dependencies'][dep] = 'workspace:*'
|
||||
json.dump(data, open(path, 'w'), indent=2)
|
||||
open(path, 'a').write('\n')
|
||||
"
|
||||
done
|
||||
git add -A
|
||||
git commit -m "chore: restore workspace:* deps on main" || true
|
||||
|
||||
git push origin main
|
||||
git push github main 2>/dev/null || true
|
||||
info "Merged to main"
|
||||
|
||||
# Clean up release branch
|
||||
git branch -d "$BRANCH"
|
||||
git push origin --delete "$BRANCH" 2>/dev/null || true
|
||||
git push github --delete "$BRANCH" 2>/dev/null || true
|
||||
info "Release branch cleaned up"
|
||||
|
||||
echo ""
|
||||
info "Release $TAG complete! 🎉"
|
||||
echo ""
|
||||
echo " Published:"
|
||||
for pkg in core fs cli; do
|
||||
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
|
||||
echo " https://www.npmjs.com/package/$PKG_NAME"
|
||||
done
|
||||
else
|
||||
# RC: just push the branch
|
||||
git push origin "$BRANCH"
|
||||
git push github "$BRANCH" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
info "Prerelease $VERSION published! 🏷️"
|
||||
echo ""
|
||||
echo " Install for testing:"
|
||||
echo " bun add -g @ocas/cli@rc"
|
||||
echo ""
|
||||
echo " When verified, run final release:"
|
||||
echo " ./scripts/publish.sh"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user