Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd1a124fd6 | |||
| 381a87e02c | |||
| 171d08351a | |||
| a05a563fdd | |||
| b0274761e3 | |||
| 2f55bd3817 | |||
| 5d871a1357 | |||
| 01cf04fb3a | |||
| 1531255698 | |||
| 1f5eb7883c | |||
| db4072396b | |||
| 40dacee1be | |||
| 4659258693 | |||
| f5eef854a3 | |||
| 741fea9e51 | |||
| 522b782571 | |||
| 949663545f | |||
| 544226041e | |||
| dbfaf01031 | |||
| 4ba3a00de9 | |||
| b2430172a2 | |||
| dd5cb49168 | |||
| 48c099ba03 | |||
| 22ec210813 | |||
| 578973fd62 | |||
| 3e93794c59 | |||
| 3b089c2291 | |||
| 5414332b7f | |||
| 146e7f4b69 | |||
| 071864c339 | |||
| 24bd04b884 | |||
| 7ae13289a9 | |||
| 0e71f4d88d | |||
| 7871db748f | |||
| e3c84c5794 | |||
| 693feb19e2 | |||
| 050fc8eee4 | |||
| 7c145597d5 | |||
| 73db7b9cac | |||
| 02a1c18ac5 | |||
| 1bd4edbdd1 | |||
| 49f096b18f | |||
| 0dfc26fcfe | |||
| 6b9ebd1796 | |||
| b3879c583a | |||
| 01d4f0fa14 | |||
| d79d0227fa | |||
| e4e4ce0f73 | |||
| 13b12ef50c | |||
| a9d43abf28 | |||
| 6f054f6447 | |||
| 5e9b266ebd | |||
| c4d9205eb2 | |||
| 9d17be8b7b | |||
| 7e9bd26fec | |||
| 3168bf55c3 | |||
| 00a536631a | |||
| f8103e20ce | |||
| 5c567dc455 | |||
| 08a2bddcf0 | |||
| 6fc3b9030b | |||
| 36ebf42f2f | |||
| f286df91f0 | |||
| 8971206a3e | |||
| 807c0942e8 | |||
| 5c0670e69b | |||
| c90efda4dd | |||
| 2d4f76330c | |||
| 768ae20a24 | |||
| 72ba313d12 | |||
| 1fe6035be5 | |||
| e3f7ec1a11 | |||
| 9ac08e5893 | |||
| fe56634160 | |||
| dd75c1f39d | |||
| 32b520b2a4 | |||
| 05f3246e5b | |||
| 736d7e7374 | |||
| 3cfcf6db89 | |||
| 5f562cbc5a | |||
| 234bd0d611 | |||
| ff4c4f3eff | |||
| 39e4e4faca | |||
| 799ef0af92 | |||
| 845ecb635e | |||
| aa0584dea7 | |||
| 12b61b4625 | |||
| 22dec2ae7a | |||
| 4d6d090bb9 | |||
| 6d89c60401 | |||
| 185c4c779b | |||
| b49da8ebbd | |||
| 298a8ec626 | |||
| 67620e0d57 | |||
| fcb514ef50 | |||
| ca334692b7 | |||
| 54f225a514 | |||
| 4ebae73257 | |||
| 78ab8538cd | |||
| 561f2a33b7 | |||
| 3fb179abde | |||
| f22eb5deab | |||
| 0735e7ca24 | |||
| 0041fc4e23 | |||
| e398d090b0 | |||
| 9965e75c22 | |||
| 385815534f | |||
| e53f473fc2 | |||
| 8c075b3310 | |||
| 0f10580937 | |||
| 56e3253829 | |||
| 7ff3e438be | |||
| 828514def5 | |||
| c83e98cd7e | |||
| db5272a64a | |||
| e1c4fb86bc | |||
| 0f1bf0383a | |||
| 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 |
+2
-2
@@ -24,13 +24,13 @@ This is the only node in the entire store where `type === hash`. It is the root
|
||||
|
||||
## What Bootstrap Registers
|
||||
|
||||
`bootstrap(store, varStore?)` is called once when a [[Store]] is opened (and is idempotent). It registers:
|
||||
`bootstrap(store)` is called synchronously once when a [[Store]] is opened (and is idempotent). It takes a single unified `Store` parameter. It registers:
|
||||
|
||||
1. **The meta-schema** — defines the structure of all schemas (allowed JSON Schema keywords)
|
||||
2. **Primitive type schemas** — `@ocas/string`, `@ocas/number`, `@ocas/object`, `@ocas/array`, `@ocas/bool`
|
||||
3. **Output schemas** — 18 `@ocas/output/*` schemas for [[Render System|CLI envelope]] types
|
||||
|
||||
All of these are stored as regular CAS nodes, addressable by hash. When a [[Variable|varStore]] is provided, bootstrap also writes each `@ocas/*` builtin name → hash binding into the variable store, making them resolvable like any other variable.
|
||||
All of these are stored as regular CAS nodes, addressable by hash. Bootstrap writes each `@ocas/*` builtin name → hash binding into the unified store's `var` sub-store, making them resolvable like any other variable.
|
||||
|
||||
## Idempotency
|
||||
|
||||
|
||||
+39
-4
@@ -17,7 +17,7 @@ The `ocas` CLI is the primary interface for interacting with an OCAS [[Store]].
|
||||
| 2 | `OCAS_HOME` env var | `export OCAS_HOME=/data/ocas` |
|
||||
| 3 | Default | `~/.ocas` |
|
||||
|
||||
The variable database lives at `<home>/variables.db` by default, overridable with `--var-db <path>`.
|
||||
The SQLite database lives at `<home>/_store.db`.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -32,6 +32,7 @@ ocas verify <hash> # check integrity + schema validity
|
||||
ocas refs <hash> # list direct ocas_ref edges
|
||||
ocas walk <hash> # recursive DAG traversal
|
||||
ocas list --type <hash|name> # list nodes by type
|
||||
ocas list --tag <expr> # filter by tag
|
||||
ocas list-schema # list all schema hashes
|
||||
ocas list-meta # list meta-schema hashes
|
||||
ocas gc # garbage collection
|
||||
@@ -40,14 +41,22 @@ ocas gc # garbage collection
|
||||
### [[Variable]] Management
|
||||
|
||||
```bash
|
||||
ocas var set <name> <hash> [--tag key:value] [--tag label]
|
||||
ocas var set <name> <hash>
|
||||
ocas var get <name> --schema <hash>
|
||||
ocas var delete <name> [--schema <hash>]
|
||||
ocas var list [prefix] [--schema <hash>] [--tag ...]
|
||||
ocas var tag <name> --schema <hash> <operations...>
|
||||
ocas var list [prefix] [--schema <hash>]
|
||||
ocas var history <name> [--schema <hash>]
|
||||
```
|
||||
|
||||
### Tagging
|
||||
|
||||
```bash
|
||||
ocas tag <target> <tag>... # attach tags (key:value) or labels (bare)
|
||||
ocas untag <target> <tag>... # remove tags by key or label
|
||||
```
|
||||
|
||||
Tags apply to any target (variable or node). `ocas get` and `ocas var get` include tag info in output.
|
||||
|
||||
### [[Render System|Template & Render]]
|
||||
|
||||
```bash
|
||||
@@ -59,12 +68,36 @@ ocas render <hash> [--resolution n] [--decay n] [--epsilon n]
|
||||
ocas render --pipe/-p [options]
|
||||
```
|
||||
|
||||
### Bundle Export / Import
|
||||
|
||||
```bash
|
||||
ocas export <root>... -o <bundle.tar> # write CAS closure of roots to tar
|
||||
ocas import <bundle.tar> [--scope @new] # merge bundle into current store
|
||||
```
|
||||
|
||||
`ocas export` walks `cas_ref` edges **and** schema chains from each root, then
|
||||
writes a self-contained POSIX-tar archive containing every reachable CAS node
|
||||
(`cas/<hash>.bin`, CBOR-encoded), every variable whose `value` is in-closure
|
||||
(`vars.jsonl`), and every tag attached to an in-closure target (`tags.jsonl`).
|
||||
|
||||
`ocas import` is content-addressed and idempotent — re-importing the same
|
||||
bundle is a no-op. `--scope @new` rewrites the leading `@scope/` of every
|
||||
imported variable name except `@ocas/*` builtins.
|
||||
|
||||
`--store <bundle.tar>` is a global flag that swaps the store backend for
|
||||
read-only commands. Internally this calls `loadBundleStore()` (from
|
||||
`@ocas/core`) which returns an in-memory `Store` populated from the bundle,
|
||||
without touching `~/.ocas`. Write commands (`put`, `tag`, `gc`, `import`,
|
||||
`var set`, `template set`, …) refuse with an explicit error when `--store`
|
||||
is set.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--home <path>` | Store directory |
|
||||
| `--var-db <path>` | Variable database path |
|
||||
| `--store <bundle.tar>` | Open a bundle as a read-only store (write commands rejected) |
|
||||
| `--json` | Compact JSON output (no pretty-printing) |
|
||||
| `--pipe`, `-p` | Read from stdin (`put`/`hash`: raw JSON; `render`: envelope) |
|
||||
| `--schema <hash>` | Schema filter for var commands |
|
||||
@@ -76,6 +109,8 @@ ocas render --pipe/-p [options]
|
||||
| `--limit <n>` | Max results to return (default: 100) |
|
||||
| `--offset <n>` | Skip first N results (default: 0) |
|
||||
| `--desc` | Sort descending (default: ascending) |
|
||||
| `-o <path>` | Output file path (used by `export`) |
|
||||
| `--scope @new` | Variable scope remap on import (used by `import`) |
|
||||
|
||||
## Variable Names
|
||||
|
||||
|
||||
+17
-13
@@ -13,16 +13,24 @@ The Store is the abstract storage interface at the heart of OCAS. All operations
|
||||
|
||||
```typescript
|
||||
type Store = {
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash>;
|
||||
get(hash: Hash): CasNode | null;
|
||||
has(hash: Hash): boolean;
|
||||
listAll(): Hash[];
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[];
|
||||
listMeta(options?: ListOptions): ListEntry[];
|
||||
listSchemas(options?: ListOptions): ListEntry[];
|
||||
cas: CasStore;
|
||||
var: VarStore;
|
||||
tag: TagStore;
|
||||
};
|
||||
```
|
||||
|
||||
### CasStore
|
||||
|
||||
Content-addressed storage. Handles `put`, `get`, `has`, and list operations over CAS nodes.
|
||||
|
||||
### VarStore
|
||||
|
||||
Mutable name → hash bindings (variables). Replaces the old standalone `VariableStore`.
|
||||
|
||||
### TagStore
|
||||
|
||||
Tag-based grouping and querying of CAS nodes.
|
||||
|
||||
### ListOptions & ListEntry
|
||||
|
||||
List methods accept optional `ListOptions` for sorting and pagination:
|
||||
@@ -43,16 +51,12 @@ When `limit` is `undefined`, all results are returned (no cap). The [[CLI]] defa
|
||||
|
||||
In-memory `Map<Hash, CasNode>`. Used in tests and for ephemeral computation (e.g. computing a hash without persisting). Created via `createMemoryStore()`.
|
||||
|
||||
### FsStore
|
||||
### FsStore (`@ocas/fs`)
|
||||
|
||||
Filesystem-backed store (`@ocas/fs`). Nodes are serialized as CBOR files under a content-addressed directory tree. Created via `openStore(path)`, which:
|
||||
Filesystem-backed store. CAS nodes are stored as CBOR files; variables and tags use SQLite (`node:sqlite`). Created via `openStore(path)`, which:
|
||||
|
||||
1. Creates the directory if it doesn't exist
|
||||
2. Runs [[Bootstrap]] automatically
|
||||
3. Returns a ready-to-use Store
|
||||
|
||||
The default location is `~/.ocas`, configurable via `OCAS_HOME` environment variable or `--home` [[CLI]] flag.
|
||||
|
||||
## VariableStore
|
||||
|
||||
A companion store for [[Variable]] bindings, backed by SQLite. Created via `createVariableStore(dbPath, store)`. It sits alongside the CAS store — variables point into the CAS but live in their own database.
|
||||
|
||||
+9
-5
@@ -41,18 +41,22 @@ The `@scope` prefix ensures variable names are visually distinct from 13-charact
|
||||
## Operations
|
||||
|
||||
```bash
|
||||
ocas var set <name> <hash> [--tag env:prod] [--tag pinned]
|
||||
ocas var set <name> <hash>
|
||||
ocas var get <name> --schema <hash>
|
||||
ocas var delete <name> [--schema <hash>]
|
||||
ocas var list [prefix] [--schema <hash>] [--tag env:prod]
|
||||
ocas var tag <name> --schema <hash> status:active pinned :archived
|
||||
ocas var list [prefix] [--schema <hash>]
|
||||
```
|
||||
|
||||
`var set` is an upsert — creates or updates. Name prefix filtering replaces the old scope concept: `var list myapp/` returns all variables under `myapp/`.
|
||||
`var set` is an upsert — creates or updates. Name prefix filtering replaces the old scope concept: `var list myapp/` returns all variables under `myapp/`. Tagging is now handled by the top-level `ocas tag` / `ocas untag` commands (see [[CLI]]).
|
||||
|
||||
## Tags and Labels
|
||||
|
||||
Tags and labels share a unified command (`var tag`):
|
||||
Tags and labels are managed via top-level `ocas tag` / `ocas untag` commands:
|
||||
|
||||
```bash
|
||||
ocas tag <target> env:prod pinned # set tag + label
|
||||
ocas untag <target> env pinned # remove by key/label
|
||||
```
|
||||
|
||||
- **Tags** are `key:value` pairs — same-key mutually exclusive (e.g. `env:prod` replaces `env:dev`)
|
||||
- **Labels** are bare strings (e.g. `pinned`, `important`)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: corepack enable && pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run check
|
||||
|
||||
- name: Test
|
||||
run: pnpm run test
|
||||
@@ -3,3 +3,4 @@ dist/
|
||||
*.d.ts.map
|
||||
*.tsbuildinfo
|
||||
.worktrees/
|
||||
bun.lock
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
name: "e2e-check"
|
||||
description: "Docker-isolated E2E testing of json-cas CLI. Preparer builds from scratch, tester runs scenarios, reporter files bugs."
|
||||
|
||||
roles:
|
||||
preparer:
|
||||
description: "Spins up Docker container, copies repo, installs, builds, runs unit tests"
|
||||
goal: "You set up a clean Docker environment for E2E testing. Your job is to start a container, install deps, build, lint, run unit tests, and initialize a CAS store. Report any setup failures as bugs."
|
||||
capabilities:
|
||||
- docker
|
||||
procedure: |
|
||||
1. Start a detached container:
|
||||
```bash
|
||||
docker run -d --name json-cas-e2e \
|
||||
-v "<repoPath>:/src:ro" \
|
||||
-w /workspace \
|
||||
oven/bun:latest \
|
||||
sleep 3600
|
||||
```
|
||||
|
||||
2. Copy repo and install:
|
||||
```bash
|
||||
docker exec json-cas-e2e bash -c 'cp -r /src/. /workspace/ && cd /workspace && bun install'
|
||||
```
|
||||
✅ exit code 0, no missing peer deps
|
||||
|
||||
3. Build:
|
||||
```bash
|
||||
docker exec json-cas-e2e bash -c 'cd /workspace && bun run build'
|
||||
```
|
||||
✅ exit code 0, no type errors
|
||||
|
||||
4. Lint:
|
||||
```bash
|
||||
docker exec json-cas-e2e bash -c 'cd /workspace && bun run check'
|
||||
```
|
||||
✅ exit code 0
|
||||
|
||||
5. Unit tests:
|
||||
```bash
|
||||
docker exec json-cas-e2e bash -c 'cd /workspace && bun test'
|
||||
```
|
||||
✅ all pass (ignore dist/ false positives)
|
||||
|
||||
6. Init CAS store:
|
||||
```bash
|
||||
docker exec json-cas-e2e bash -c 'mkdir -p /tmp/cas-test && cd /workspace && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test init && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test bootstrap'
|
||||
```
|
||||
|
||||
**Any failure here is a high-severity bug** — it means a clean environment can't build/run the project.
|
||||
|
||||
Set $status=ready if all steps pass. Set $status=setup_failed with failures list if anything breaks.
|
||||
|
||||
output: "Setup result summary."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "ready" }
|
||||
containerName: { type: string }
|
||||
storePath: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, containerName, storePath, repoPath]
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "setup_failed" }
|
||||
failures:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
title: { type: string }
|
||||
command: { type: string }
|
||||
expected: { type: string }
|
||||
actual: { type: string }
|
||||
severity: { type: string }
|
||||
phase: { type: string }
|
||||
required: [title, command, expected, actual, severity, phase]
|
||||
repoPath: { type: string }
|
||||
required: [$status, failures, repoPath]
|
||||
|
||||
tester:
|
||||
description: "Runs CLI scenarios against the prepared Docker environment"
|
||||
goal: "You are an exploratory QA agent. The Docker container is already running with the project built and a CAS store initialized. Run CLI test scenarios and report bugs."
|
||||
capabilities:
|
||||
- testing
|
||||
- cli
|
||||
procedure: |
|
||||
The container `{{{containerName}}}` is already running with the project built.
|
||||
Store path: `{{{storePath}}}`.
|
||||
|
||||
Run all commands via:
|
||||
```bash
|
||||
docker exec {{{containerName}}} bash -c 'cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}} <subcommand>'
|
||||
```
|
||||
|
||||
Define a shorthand in your notes:
|
||||
`CMD="cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}}"`
|
||||
|
||||
## Phase 1: CAS Core Operations
|
||||
|
||||
1. **bootstrap** — `$CMD bootstrap`
|
||||
Expected: prints meta-schema hash (13-char Base32)
|
||||
|
||||
2. **schema put** — Create `/tmp/test-schema.json` in container, then `$CMD schema put /tmp/test-schema.json`
|
||||
Schema: `{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"number"}},"required":["name"],"additionalProperties":false}`
|
||||
Expected: prints type hash
|
||||
|
||||
3. **schema get** — `$CMD schema get <type-hash>`
|
||||
Expected: returns the schema JSON
|
||||
|
||||
4. **schema list** — `$CMD schema list`
|
||||
Expected: lists registered schemas
|
||||
|
||||
5. **put** — Create data file, `$CMD put <type-hash> /tmp/test-node.json`
|
||||
Data: `{"name":"Alice","age":30}`
|
||||
Expected: prints node hash
|
||||
|
||||
6. **get** — `$CMD get <node-hash>`
|
||||
Expected: returns node JSON
|
||||
|
||||
7. **has (exists)** — `$CMD has <node-hash>`
|
||||
Expected: true
|
||||
|
||||
8. **has (not exists)** — `$CMD has AAAAAAAAAAAAA`
|
||||
Expected: false
|
||||
|
||||
9. **verify** — `$CMD verify <node-hash>`
|
||||
Expected: ok
|
||||
|
||||
10. **refs** — `$CMD refs <node-hash>`
|
||||
Expected: lists refs (may be empty)
|
||||
|
||||
11. **walk** — `$CMD walk <node-hash>`
|
||||
Expected: shows traversal tree
|
||||
|
||||
12. **hash (dry run)** — `$CMD hash <type-hash> /tmp/test-node.json`
|
||||
Expected: same hash as put
|
||||
|
||||
13. **cat** — `$CMD cat <node-hash>`
|
||||
Expected: full node output
|
||||
|
||||
14. **cat --payload** — `$CMD cat <node-hash> --payload`
|
||||
Expected: payload only (no type wrapper)
|
||||
|
||||
## Phase 2: Schema Validation
|
||||
|
||||
1. **Invalid node** — `$CMD put <type-hash> /tmp/bad-node.json` where bad-node = `{"name":123}`
|
||||
Expected: validation error, non-zero exit
|
||||
|
||||
2. **schema validate** — `$CMD schema validate <node-hash>`
|
||||
Expected: valid for good node
|
||||
|
||||
3. **Non-existent schema** — `$CMD put AAAAAAAAAAAAA /tmp/test-node.json`
|
||||
Expected: error about missing schema
|
||||
|
||||
## Phase 3: Variable System
|
||||
|
||||
1. **var set** — `$CMD var set myapp/config <node-hash>`
|
||||
Expected: creates variable
|
||||
|
||||
2. **var get** — `$CMD var get myapp/config --schema <type-hash>`
|
||||
Expected: returns variable
|
||||
|
||||
3. **var list** — `$CMD var list`
|
||||
Expected: shows all variables
|
||||
|
||||
4. **var list prefix** — `$CMD var list myapp/`
|
||||
Expected: filtered results
|
||||
|
||||
5. **var set (update)** — Put a second node, `$CMD var set myapp/config <new-hash>`
|
||||
Expected: upsert succeeds
|
||||
|
||||
6. **var tag** — `$CMD var tag myapp/config --schema <type-hash> env:prod important`
|
||||
Expected: adds tag and label
|
||||
|
||||
7. **var list --tag** — `$CMD var list --tag env:prod`
|
||||
Expected: finds tagged variable
|
||||
|
||||
8. **var list --tag (label)** — `$CMD var list --tag important`
|
||||
Expected: finds labeled variable
|
||||
|
||||
9. **var tag remove** — `$CMD var tag myapp/config --schema <type-hash> :important`
|
||||
Expected: removes label
|
||||
|
||||
10. **var delete** — `$CMD var delete myapp/config`
|
||||
Expected: deletes variable
|
||||
|
||||
11. **var get (deleted)** — `$CMD var get myapp/config --schema <type-hash>`
|
||||
Expected: not found error
|
||||
|
||||
## Phase 4: Template System
|
||||
|
||||
1. **template set** — Create template file, `$CMD template set <type-hash> /tmp/test.liquid`
|
||||
Template: `Name: {{ payload.name }}, Age: {{ payload.age }}`
|
||||
Expected: success
|
||||
|
||||
2. **template get** — `$CMD template get <type-hash>`
|
||||
Expected: returns template text
|
||||
|
||||
3. **template list** — `$CMD template list`
|
||||
Expected: lists templates
|
||||
|
||||
4. **template delete** — `$CMD template delete <type-hash>`
|
||||
Expected: success
|
||||
|
||||
5. **template get (deleted)** — `$CMD template get <type-hash>`
|
||||
Expected: not found error
|
||||
|
||||
## Phase 5: Render
|
||||
|
||||
1. Re-register template, then `$CMD render <node-hash>`
|
||||
Expected: rendered output with payload values filled in
|
||||
|
||||
2. **render --resolution** — `$CMD render <node-hash> --resolution 0.5`
|
||||
Expected: different resolution output
|
||||
|
||||
3. **render (bad hash)** — `$CMD render AAAAAAAAAAAAA`
|
||||
Expected: graceful error, non-zero exit
|
||||
|
||||
## Phase 6: GC
|
||||
|
||||
1. **gc basic** — `$CMD gc`
|
||||
Expected: runs without error
|
||||
|
||||
2. **gc preserves referenced** — Verify `$CMD has <node-hash>` still true
|
||||
|
||||
3. **gc collects orphans** — Put an orphan node (not in any variable), run gc, check it's gone
|
||||
|
||||
## Phase 7: Edge Cases & Error Handling
|
||||
|
||||
1. `$CMD get AAAAAAAAAAAAA` — non-existent
|
||||
2. `$CMD put <type-hash> /nonexistent/file.json` — missing file
|
||||
3. `$CMD var set "" <hash>` — empty name
|
||||
4. `$CMD var set "bad name!" <hash>` — invalid name chars
|
||||
5. `$CMD schema put /tmp/bad-schema.json` — `{"type":"invalid"}`
|
||||
6. `$CMD` with no subcommand — should show help
|
||||
7. `$CMD --store /nonexistent/path get <hash>` — bad store path
|
||||
|
||||
## Recording Results
|
||||
|
||||
For each scenario:
|
||||
- ✅ Pass: works as expected
|
||||
- ❌ Fail: unexpected behavior, crash, wrong output
|
||||
- ⚠️ Questionable: works but confusing UX
|
||||
|
||||
Collect all ❌ and ⚠️. For each, record:
|
||||
- Title (concise description)
|
||||
- Command (exact command run)
|
||||
- Expected behavior
|
||||
- Actual behavior (include actual output)
|
||||
- Severity: critical / high / medium / low
|
||||
- Phase: which test phase
|
||||
|
||||
## CRITICAL: Frontmatter Output Format
|
||||
|
||||
Your response MUST start with YAML frontmatter. The `bugs` field MUST be an array of objects, NOT strings.
|
||||
|
||||
Example of CORRECT frontmatter:
|
||||
```yaml
|
||||
---
|
||||
$status: bugs_found
|
||||
containerName: json-cas-e2e
|
||||
repoPath: /path/to/repo
|
||||
bugs:
|
||||
- title: "put does not validate data against schema"
|
||||
command: "json-cas put <hash> bad-data.json"
|
||||
expected: "Validation error, non-zero exit"
|
||||
actual: "Accepted invalid data, exit 0"
|
||||
severity: "high"
|
||||
phase: "Schema Validation"
|
||||
---
|
||||
```
|
||||
|
||||
Do NOT write bugs as plain strings like `- some bug description`. Each bug MUST be an object with all 6 fields.
|
||||
|
||||
If all tests pass:
|
||||
```yaml
|
||||
---
|
||||
$status: all_passed
|
||||
containerName: json-cas-e2e
|
||||
---
|
||||
```
|
||||
|
||||
output: "Summary of all phases with pass/fail counts. Set $status."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "bugs_found" }
|
||||
bugs:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
title: { type: string }
|
||||
command: { type: string }
|
||||
expected: { type: string }
|
||||
actual: { type: string }
|
||||
severity: { type: string }
|
||||
phase: { type: string }
|
||||
required: [title, command, expected, actual, severity, phase]
|
||||
containerName: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, bugs, containerName]
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "all_passed" }
|
||||
containerName: { type: string }
|
||||
required: [$status, containerName]
|
||||
|
||||
reporter:
|
||||
description: "Opens Gitea issues for each bug found by the tester"
|
||||
goal: "You are a bug reporter. You create well-formatted Gitea issues for each bug found during E2E testing."
|
||||
capabilities:
|
||||
- issue-management
|
||||
procedure: |
|
||||
1. Parse the bugs array from the tester's output
|
||||
2. Group bugs by severity (critical first)
|
||||
3. For each bug, create a Gitea issue:
|
||||
```bash
|
||||
tea issues create -r uncaged/json-cas \
|
||||
-t "[E2E] <title>" \
|
||||
-d "## Bug Report (E2E Check)
|
||||
|
||||
**Phase:** <phase>
|
||||
**Severity:** <severity>
|
||||
|
||||
**Command:**
|
||||
\`\`\`
|
||||
<exact command>
|
||||
\`\`\`
|
||||
|
||||
**Expected:** <expected>
|
||||
|
||||
**Actual:** <actual>
|
||||
|
||||
---
|
||||
_Reported by e2e-check workflow (Docker isolated)_"
|
||||
```
|
||||
|
||||
⚠️ If `tea issues create` fails with long body, use Gitea REST API:
|
||||
```bash
|
||||
eval "$(cfg env)" && GITEA_TOKEN=$(cfg get GITEA_TOKEN)
|
||||
curl -s -X POST "https://git.shazhou.work/api/v1/repos/uncaged/json-cas/issues" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"[E2E] ...","body":"..."}'
|
||||
```
|
||||
|
||||
4. Collect created issue numbers
|
||||
|
||||
output: "List created issues. Set $status."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "reported" }
|
||||
issues:
|
||||
type: array
|
||||
items: { type: string }
|
||||
required: [$status, issues]
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "partial" }
|
||||
created: { type: number }
|
||||
failed: { type: number }
|
||||
required: [$status, created, failed]
|
||||
|
||||
cleanup:
|
||||
description: "Stops and removes the Docker container"
|
||||
goal: "You clean up the Docker environment after testing is complete."
|
||||
capabilities:
|
||||
- docker
|
||||
procedure: |
|
||||
Stop and remove the container:
|
||||
```bash
|
||||
docker stop {{{containerName}}} && docker rm {{{containerName}}}
|
||||
```
|
||||
|
||||
Verify it's gone:
|
||||
```bash
|
||||
docker ps -a --filter name={{{containerName}}} --format '{{.Names}}'
|
||||
```
|
||||
Expected: empty output.
|
||||
|
||||
output: "Cleanup result."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "cleaned" }
|
||||
required: [$status]
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "cleanup_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "preparer", prompt: "Set up Docker environment for E2E testing. Repo at {{{repoPath}}}." }
|
||||
preparer:
|
||||
ready: { role: "tester", prompt: "Environment ready. Container: {{{containerName}}}, store: {{{storePath}}}. Run all test scenarios." }
|
||||
setup_failed: { role: "reporter", prompt: "Setup failures found. File these as bugs: {{{failures}}}" }
|
||||
tester:
|
||||
all_passed: { role: "cleanup", prompt: "All tests passed. Clean up container {{{containerName}}}." }
|
||||
bugs_found: { role: "reporter", prompt: "File these bugs as Gitea issues: {{{bugs}}}" }
|
||||
reporter:
|
||||
reported: { role: "cleanup", prompt: "Bugs filed: {{{issues}}}. Clean up container {{{containerName}}}." }
|
||||
partial: { role: "cleanup", prompt: "Filed {{{created}}} issues, {{{failed}}} failed. Clean up container {{{containerName}}}." }
|
||||
cleanup:
|
||||
cleaned: { role: "$END", prompt: "E2E check complete. Environment cleaned up." }
|
||||
cleanup_failed: { role: "$END", prompt: "E2E check complete but cleanup failed: {{{error}}}" }
|
||||
@@ -0,0 +1,141 @@
|
||||
name: "release"
|
||||
description: "Release workflow for proman-managed projects. Runs pre-flight checks, bumps versions, publishes to npm, verifies, and syncs to GitHub."
|
||||
roles:
|
||||
releaser:
|
||||
description: "Executes the full release pipeline: pre-flight → bump → publish → verify → sync"
|
||||
goal: "You are a release engineer. You execute a safe, verified release of a proman-managed project."
|
||||
capabilities:
|
||||
- git
|
||||
- npm
|
||||
procedure: |
|
||||
You will receive a repo slug (owner/repo) and local repo path in the task prompt.
|
||||
cd into the local repo path for all operations.
|
||||
|
||||
**Step 1 — Pre-flight checks (ALL must pass, abort on first failure):**
|
||||
|
||||
1a. On `main` branch:
|
||||
```bash
|
||||
BRANCH=$(git branch --show-current)
|
||||
```
|
||||
If not `main`, output `$status: not_ready` with reason.
|
||||
|
||||
1b. Clean working tree:
|
||||
```bash
|
||||
git status --porcelain
|
||||
```
|
||||
If output is non-empty, output `$status: not_ready` with reason.
|
||||
|
||||
1c. Pull latest and check for divergence:
|
||||
```bash
|
||||
git pull --ff-only
|
||||
```
|
||||
If this fails (diverged), output `$status: not_ready` with reason.
|
||||
|
||||
1d. Pending changesets exist:
|
||||
```bash
|
||||
ls .changeset/*.md 2>/dev/null | grep -v README.md | grep -v config.json
|
||||
```
|
||||
If no `.md` files found (excluding README.md and config.json), output `$status: not_ready` with reason "no pending changesets".
|
||||
|
||||
1e. CI is green on latest commit:
|
||||
```bash
|
||||
GITEA_TOKEN=$(cfg get GITEA_TOKEN)
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
curl -s "https://git.shazhou.work/api/v1/repos/<owner/repo>/commits/$HEAD_SHA/status" \
|
||||
-H "Authorization: token $GITEA_TOKEN" | jq '{state: .state}'
|
||||
```
|
||||
If state is not `success`, output `$status: not_ready` with reason.
|
||||
If state is `pending`, wait up to 5 minutes (poll every 30s), then fail if still pending.
|
||||
|
||||
**Step 2 — Bump versions:**
|
||||
```bash
|
||||
npx proman bump
|
||||
```
|
||||
This consumes changesets, bumps package.json versions, generates CHANGELOG entries, and deletes consumed changeset files.
|
||||
If it fails, output `$status: failed` with the error.
|
||||
|
||||
Record the bumped versions from the output for later verification.
|
||||
|
||||
**Step 3 — Publish:**
|
||||
```bash
|
||||
npx proman publish
|
||||
```
|
||||
This runs: install → build → test → check → npm publish → git commit → git tag → push.
|
||||
If it fails, output `$status: failed` with the error and which packages succeeded/failed.
|
||||
|
||||
**Step 4 — Verify:**
|
||||
|
||||
4a. Check npm registry for each published package:
|
||||
```bash
|
||||
npm view <package-name>@<new-version> version
|
||||
```
|
||||
Each should return the new version string.
|
||||
|
||||
4b. Check git tags exist locally:
|
||||
```bash
|
||||
git tag -l '<package-name>@v<new-version>'
|
||||
```
|
||||
|
||||
4c. Check Gitea has the release commit:
|
||||
```bash
|
||||
GITEA_TOKEN=$(cfg get GITEA_TOKEN)
|
||||
curl -s "https://git.shazhou.work/api/v1/repos/<owner/repo>/branches/main" \
|
||||
-H "Authorization: token $GITEA_TOKEN" | jq -r '.commit.message'
|
||||
```
|
||||
Should start with `release: v`.
|
||||
|
||||
If any verification fails, output `$status: verify_failed` with details.
|
||||
|
||||
**Step 5 — Sync to GitHub:**
|
||||
|
||||
Check if a GitHub remote exists:
|
||||
```bash
|
||||
git remote -v | grep github
|
||||
```
|
||||
The remote might be named `origin` or `github` — use whichever points to github.com.
|
||||
|
||||
If a GitHub remote exists:
|
||||
```bash
|
||||
git push <github-remote> main --tags
|
||||
```
|
||||
If no GitHub remote, skip this step and note it in the output.
|
||||
|
||||
**Step 6 — Output results:**
|
||||
Output `$status: released` with:
|
||||
- List of published packages and versions
|
||||
- Git tags created
|
||||
- GitHub sync status
|
||||
|
||||
output: "Report the release result. Set $status to released, not_ready, failed, or verify_failed."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "released" }
|
||||
packages: { type: string }
|
||||
tags: { type: string }
|
||||
githubSync: { type: string }
|
||||
required: [$status, packages, tags]
|
||||
- properties:
|
||||
$status: { const: "not_ready" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
partialPublish: { type: string }
|
||||
required: [$status, reason]
|
||||
- properties:
|
||||
$status: { const: "verify_failed" }
|
||||
reason: { type: string }
|
||||
packages: { type: string }
|
||||
required: [$status, reason]
|
||||
|
||||
graph:
|
||||
$START:
|
||||
new: { role: "releaser", prompt: "Release {{{repo}}} from {{{repoPath}}}." }
|
||||
resume: { role: "releaser", prompt: "Retry release for {{{repo}}} from {{{repoPath}}}." }
|
||||
releaser:
|
||||
released: { role: "$END", prompt: "✅ Release complete. Published: {{{packages}}}. Tags: {{{tags}}}. GitHub: {{{githubSync}}}" }
|
||||
not_ready: { role: "$END", prompt: "⏸ Release aborted — pre-flight failed: {{{reason}}}" }
|
||||
failed: { role: "$END", prompt: "❌ Release failed: {{{reason}}}" }
|
||||
verify_failed: { role: "$END", prompt: "⚠️ Release may be partial — verification failed: {{{reason}}}" }
|
||||
@@ -120,15 +120,15 @@ roles:
|
||||
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
|
||||
4. `cd .worktrees/retrospect/<short-slug> && bun install`
|
||||
4. `cd .worktrees/retrospect/<short-slug> && pnpm install`
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then apply changes:
|
||||
6. Read the change plan from CAS: `uwf cas get <plan hash>`
|
||||
7. Apply each edit from the plan to the workflow YAML
|
||||
8. Update or add tests as specified in the plan
|
||||
9. Run `bun run build` and `bun test` to verify
|
||||
10. Run `bun run check` for lint
|
||||
9. Run `pnpm run build` and `pnpm test` to verify
|
||||
10. Run `pnpm run check` for lint
|
||||
11. Commit with message: `improve: <workflow-name> — <brief summary>`
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
@@ -157,8 +157,8 @@ roles:
|
||||
2. Edits should be minimal — don't rewrite working procedures
|
||||
3. New pitfall notes or instructions must be clear and actionable
|
||||
4. Tests must be updated if assertions changed
|
||||
5. `bun run build` and `bun test` must pass
|
||||
6. `bunx biome check` must pass
|
||||
5. `pnpm run build` and `pnpm test` must pass
|
||||
6. `pnpm run check` must pass
|
||||
|
||||
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
|
||||
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
@@ -212,7 +212,8 @@ roles:
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
|
||||
new: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
|
||||
resume: { role: "analyst", prompt: "Review previous analysis of thread {{{threadId}}} and continue." }
|
||||
analyst:
|
||||
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
|
||||
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
name: "review-pr"
|
||||
description: "Code review workflow for Gitea PRs. Fetches PR context, performs structured review, and submits feedback or merges."
|
||||
roles:
|
||||
fetcher:
|
||||
description: "Prepares PR context: worktree, diff, existing comments"
|
||||
goal: "You are a PR context fetcher. You set up a clean worktree at the PR branch, collect the diff, and gather existing review comments so the reviewer has everything needed."
|
||||
capabilities:
|
||||
- git
|
||||
procedure: |
|
||||
You will receive a PR number and repo slug (owner/repo) in the task prompt.
|
||||
|
||||
**Step 0 — Self-review guard (MUST do first):**
|
||||
Check who authored the PR and who is the current authenticated user:
|
||||
```bash
|
||||
GITEA_TOKEN=$(cfg get GITEA_TOKEN)
|
||||
PR_AUTHOR=$(curl -s "https://git.shazhou.work/api/v1/repos/<owner/repo>/pulls/<number>" \
|
||||
-H "Authorization: token $GITEA_TOKEN" | jq -r '.user.login')
|
||||
CURRENT_USER=$(curl -s "https://git.shazhou.work/api/v1/user" \
|
||||
-H "Authorization: token $GITEA_TOKEN" | jq -r '.login')
|
||||
```
|
||||
If `$PR_AUTHOR == $CURRENT_USER`, **STOP IMMEDIATELY**. Do NOT proceed to any further steps.
|
||||
Output frontmatter with `$status: self_review`, the author name, and reason:
|
||||
"Self-review detected: PR author and reviewer are both <username>. Project policy prohibits reviewing your own changes."
|
||||
|
||||
**Step 1 — Set up worktree:**
|
||||
1. cd into the local repo clone
|
||||
2. `git fetch origin`
|
||||
3. Get the PR branch name:
|
||||
```bash
|
||||
tea pr <number> -r <owner/repo> -o json | jq -r '.head.name'
|
||||
```
|
||||
4. Create an isolated worktree (or reuse if it exists):
|
||||
```bash
|
||||
git worktree add .worktrees/review/pr-<number> origin/<branch> 2>/dev/null || \
|
||||
(cd .worktrees/review/pr-<number> && git checkout <branch> && git pull origin <branch>)
|
||||
```
|
||||
5. Record the worktree path for downstream roles.
|
||||
|
||||
**Step 2 — Collect PR diff:**
|
||||
Use `tea` to get the actual PR diff (NOT `git diff` which may include unrelated commits):
|
||||
```bash
|
||||
tea pr <number> -r <owner/repo> --comments -o json
|
||||
```
|
||||
This gives you the PR description, labels, and comments.
|
||||
|
||||
For the actual file diff, use the Gitea API:
|
||||
```bash
|
||||
curl -s "https://git.shazhou.work/api/v1/repos/<owner/repo>/pulls/<number>/files" \
|
||||
-H "Authorization: token $(cfg get GITEA_TOKEN)" | jq '.[].filename'
|
||||
```
|
||||
|
||||
Then for each changed file, get the patch:
|
||||
```bash
|
||||
curl -s "https://git.shazhou.work/api/v1/repos/<owner/repo>/pulls/<number>/files" \
|
||||
-H "Authorization: token $(cfg get GITEA_TOKEN)" | jq -r '.[] | "=== \(.filename) ===\n\(.patch)"'
|
||||
```
|
||||
|
||||
**Step 3 — Collect existing review comments:**
|
||||
```bash
|
||||
tea pr review-comments <number> -r <owner/repo> -f id,path,line,body,reviewer -o json
|
||||
```
|
||||
This tells the reviewer what has already been commented on, so they don't repeat.
|
||||
|
||||
**Step 4 — Collect PR metadata:**
|
||||
- PR title and description
|
||||
- Changed file count and line counts (+/-)
|
||||
- Which packages/layers are affected
|
||||
- Whether a changeset exists in the diff
|
||||
|
||||
Output all collected context for the reviewer.
|
||||
|
||||
output: "Output the PR metadata, diff summary, changed files list, existing comments, and worktree path. If self-review detected, output $status: self_review immediately."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
repo: { type: string }
|
||||
prNumber: { type: integer }
|
||||
worktree: { type: string }
|
||||
changedFiles: { type: string }
|
||||
existingComments: { type: string }
|
||||
required: [$status, repo, prNumber, worktree, changedFiles, existingComments]
|
||||
- properties:
|
||||
$status: { const: "self_review" }
|
||||
repo: { type: string }
|
||||
prNumber: { type: integer }
|
||||
author: { type: string }
|
||||
reason: { type: string }
|
||||
required: [$status, repo, prNumber, author, reason]
|
||||
|
||||
reviewer:
|
||||
description: "Structured code review against checklist"
|
||||
goal: "You are a code reviewer. You perform a thorough, structured review of a PR against a defined checklist. You identify blocking issues and non-blocking suggestions separately."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
cd into the worktree path provided in the task prompt. All file reads and
|
||||
tool invocations must happen inside the worktree.
|
||||
|
||||
**Review checklist (in priority order):**
|
||||
|
||||
1. **Correctness** — Does the code do what the PR claims? Logic errors, off-by-one, null handling.
|
||||
2. **Type safety** — No `any` casts, no unsafe type assertions, generics used properly.
|
||||
3. **Error handling** — All error paths covered, no swallowed exceptions, proper error types.
|
||||
4. **Race conditions** — Concurrent access patterns, shared mutable state, async pitfalls.
|
||||
5. **Breaking changes** — API surface changes, removed exports, schema changes. Must be flagged in changeset.
|
||||
6. **Test coverage** — New code has tests, edge cases covered, no test-only code in production.
|
||||
7. **Changeset** — Check if `.changeset/` directory exists in the repo root. If yes, the project uses changesets — verify the PR includes one with correct bump type (patch/minor/major) and affected packages. If no `.changeset/` directory, skip this item.
|
||||
8. **Architecture** — Follows existing patterns, proper module boundaries, no circular deps.
|
||||
9. **Documentation** — README, JSDoc, inline comments for non-obvious logic.
|
||||
10. **Style** — Naming conventions, no console.log in production, consistent patterns.
|
||||
|
||||
**Classify each finding:**
|
||||
- **blocking** — Must fix before merge. Bugs, type safety holes, missing error handling, missing tests.
|
||||
- **non-blocking** — Good to fix but doesn't block. Style nits, minor naming, doc improvements.
|
||||
|
||||
**Rules:**
|
||||
- Do NOT repeat issues already raised in existing review comments (provided in task prompt).
|
||||
- For each finding, reference the specific file and line number.
|
||||
- If a finding maps to a checklist item, reference the item number.
|
||||
- Read full file context in the worktree when the diff alone is ambiguous.
|
||||
|
||||
**Verdict:**
|
||||
- If ANY blocking issues exist → $status=request_changes
|
||||
- If ONLY non-blocking issues exist → $status=request_changes (non-blocking items need issues)
|
||||
- If NO issues at all → $status=approve
|
||||
|
||||
output: "List all findings with file:line references and blocking/non-blocking classification. Set $status to approve or request_changes."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approve" }
|
||||
summary: { type: string }
|
||||
repo: { type: string }
|
||||
prNumber: { type: integer }
|
||||
required: [$status, summary, repo, prNumber]
|
||||
- properties:
|
||||
$status: { const: "request_changes" }
|
||||
blockingIssues: { type: string }
|
||||
nonBlockingIssues: { type: string }
|
||||
summary: { type: string }
|
||||
repo: { type: string }
|
||||
prNumber: { type: integer }
|
||||
required: [$status, summary, repo, prNumber]
|
||||
|
||||
commenter:
|
||||
description: "Submits review feedback and opens follow-up issues"
|
||||
goal: "You submit review feedback on a Gitea PR and open follow-up issues for non-blocking items. This is the unified 'feedback exit' — whether the reviewer found problems or the merger hit a conflict."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
You receive the repo, PR number, and review findings in the task prompt.
|
||||
|
||||
**Step 1 — Submit the review:**
|
||||
|
||||
For request_changes:
|
||||
```bash
|
||||
tea pr reject <number> -r <owner/repo> "<review body>"
|
||||
```
|
||||
The review body should include:
|
||||
- Summary of findings
|
||||
- Blocking issues (numbered, with file:line refs)
|
||||
- Non-blocking issues (numbered, with file:line refs, marked as "will track in issue")
|
||||
|
||||
**Step 2 — Open issues for non-blocking items:**
|
||||
|
||||
For each non-blocking finding, create a tracking issue:
|
||||
```bash
|
||||
tea issues create -r <owner/repo> \
|
||||
-t "Review feedback: <short description>" \
|
||||
-d "From PR #<number> review:\n\n<detailed description with file:line ref>\n\nRef: PR #<number>"
|
||||
```
|
||||
|
||||
**Step 3 — Output results:**
|
||||
Report what was submitted: the review state, number of issues opened, and their URLs.
|
||||
|
||||
**tea command notes:**
|
||||
- `tea pr reject` requires the reason as a positional arg, not a flag
|
||||
- Keep the review body concise but specific — file:line references are essential
|
||||
- Issue titles should be actionable: "Fix X in Y" not "Review comment about Z"
|
||||
|
||||
output: "Report the review submission result and any issues created. Set $status to submitted."
|
||||
frontmatter:
|
||||
type: object
|
||||
required: [$status, reviewState, issuesCreated]
|
||||
properties:
|
||||
$status: { const: "submitted" }
|
||||
reviewState: { type: string }
|
||||
issuesCreated: { type: integer }
|
||||
|
||||
merger:
|
||||
description: "Waits for CI, checks mergeability, and merges the PR"
|
||||
goal: "You merge an approved PR after CI passes. If CI fails or there's a merge conflict, you downgrade to request_changes and hand off to commenter."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
You receive the repo, PR number, and approval summary in the task prompt.
|
||||
|
||||
**Step 1 — Submit the approval:**
|
||||
```bash
|
||||
tea pr approve <number> -r <owner/repo> "<approval summary>"
|
||||
```
|
||||
|
||||
**Step 2 — Wait for CI:**
|
||||
Poll the commit status until it resolves (success/failure/error):
|
||||
```bash
|
||||
GITEA_TOKEN=$(cfg get GITEA_TOKEN)
|
||||
HEAD_SHA=$(curl -s "https://git.shazhou.work/api/v1/repos/<owner/repo>/pulls/<number>" \
|
||||
-H "Authorization: token $GITEA_TOKEN" | jq -r '.head.sha')
|
||||
curl -s "https://git.shazhou.work/api/v1/repos/<owner/repo>/commits/$HEAD_SHA/status" \
|
||||
-H "Authorization: token $GITEA_TOKEN" | jq '{state: .state}'
|
||||
```
|
||||
- If `pending`: wait 30 seconds and retry (up to 10 minutes)
|
||||
- If `success`: proceed to Step 3
|
||||
- If `failure` or `error`: output $status=ci_failed
|
||||
|
||||
**Step 3 — Check mergeability:**
|
||||
```bash
|
||||
curl -s "https://git.shazhou.work/api/v1/repos/<owner/repo>/pulls/<number>" \
|
||||
-H "Authorization: token $GITEA_TOKEN" | jq '{mergeable: .mergeable}'
|
||||
```
|
||||
- If `mergeable: true`: proceed to Step 4
|
||||
- If `mergeable: false`: output $status=conflict
|
||||
|
||||
**Step 4 — Merge:**
|
||||
```bash
|
||||
tea pr merge <number> -r <owner/repo> -s merge
|
||||
```
|
||||
Verify the merge succeeded:
|
||||
```bash
|
||||
curl -s "https://git.shazhou.work/api/v1/repos/<owner/repo>/pulls/<number>" \
|
||||
-H "Authorization: token $GITEA_TOKEN" | jq '{state: .state, merged: .merged}'
|
||||
```
|
||||
- If `merged: true`: output $status=merged
|
||||
- Otherwise: output $status=merge_failed
|
||||
|
||||
**Step 5 — Clean up worktree (on successful merge):**
|
||||
```bash
|
||||
cd <repo-root>
|
||||
git worktree remove .worktrees/review/pr-<number> 2>/dev/null || true
|
||||
```
|
||||
|
||||
output: "Report the merge result. Set $status to merged, ci_failed, conflict, or merge_failed."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "merged" }
|
||||
mergeCommit: { type: string }
|
||||
required: [$status]
|
||||
- properties:
|
||||
$status: { const: "ci_failed" }
|
||||
reason: { type: string }
|
||||
repo: { type: string }
|
||||
prNumber: { type: integer }
|
||||
required: [$status, reason, repo, prNumber]
|
||||
- properties:
|
||||
$status: { const: "conflict" }
|
||||
reason: { type: string }
|
||||
repo: { type: string }
|
||||
prNumber: { type: integer }
|
||||
required: [$status, reason, repo, prNumber]
|
||||
- properties:
|
||||
$status: { const: "merge_failed" }
|
||||
reason: { type: string }
|
||||
repo: { type: string }
|
||||
prNumber: { type: integer }
|
||||
required: [$status, reason, repo, prNumber]
|
||||
|
||||
graph:
|
||||
$START:
|
||||
new: { role: "fetcher", prompt: "Fetch PR context for PR #{{{prNumber}}} in repo {{{repo}}}. Local clone is at {{{repoPath}}}." }
|
||||
resume: { role: "fetcher", prompt: "Re-fetch PR context for PR #{{{prNumber}}} in repo {{{repo}}}. Local clone is at {{{repoPath}}}." }
|
||||
fetcher:
|
||||
ready: { role: "reviewer", prompt: "Review PR #{{{prNumber}}} in {{{repo}}}. Worktree: {{{worktree}}}. Changed files: {{{changedFiles}}}. Existing comments (do NOT repeat these): {{{existingComments}}}" }
|
||||
self_review: { role: "$END", prompt: "Self-review detected for PR #{{{prNumber}}} in {{{repo}}}. Author: {{{author}}}. {{{reason}}}. Workflow terminated — project policy prohibits self-review." }
|
||||
reviewer:
|
||||
approve: { role: "merger", prompt: "PR #{{{prNumber}}} in {{{repo}}} approved. Summary: {{{summary}}}. Proceed to merge." }
|
||||
request_changes: { role: "commenter", prompt: "PR #{{{prNumber}}} in {{{repo}}} needs changes. Blocking: {{{blockingIssues}}}. Non-blocking: {{{nonBlockingIssues}}}. Summary: {{{summary}}}" }
|
||||
merger:
|
||||
merged: { role: "$END", prompt: "PR merged successfully." }
|
||||
ci_failed: { role: "commenter", prompt: "PR #{{{prNumber}}} in {{{repo}}} was approved but CI failed: {{{reason}}}. Submit as request_changes asking to fix CI." }
|
||||
conflict: { role: "commenter", prompt: "PR #{{{prNumber}}} in {{{repo}}} was approved but has merge conflicts: {{{reason}}}. Submit as request_changes asking to rebase." }
|
||||
merge_failed: { role: "commenter", prompt: "PR #{{{prNumber}}} in {{{repo}}} merge failed: {{{reason}}}. Submit as request_changes with the error details." }
|
||||
commenter:
|
||||
submitted: { role: "$END", prompt: "Review feedback submitted. Workflow complete." }
|
||||
+90
-43
@@ -1,5 +1,5 @@
|
||||
name: "solve-issue"
|
||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds. Uses pnpm."
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes issue and outputs a TDD test spec"
|
||||
@@ -8,34 +8,64 @@ roles:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: |
|
||||
On first run (no previous steps):
|
||||
CRITICAL: First, determine which mode you are in by scanning the task prompt.
|
||||
Choose EXACTLY ONE mode — do NOT default to Mode A if Mode B applies.
|
||||
|
||||
**How to choose:**
|
||||
- If the prompt contains ANY of these keywords: "PR #", "PR#", "pulls/", "继续修复", "continue", "review feedback", "existing branch", "fix/", or mentions a branch name → **Mode B**
|
||||
- If the prompt was forwarded from tester with fix_spec → **Mode C**
|
||||
- Otherwise → **Mode A**
|
||||
|
||||
**Mode A — Fresh issue (first time, no existing PR):**
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
6. The test spec is stored in CAS automatically by the uwf pipeline (agents do not need to call `ocas put` directly)
|
||||
7. Output **$status=ready** with plan hash and repoPath
|
||||
|
||||
On subsequent runs (bounced back by tester with fix_spec):
|
||||
**Mode B — Continue on existing PR (prompt mentions PR, branch, or review feedback):**
|
||||
YOU MUST output $status=continue (NOT ready) when in this mode.
|
||||
1. Extract the PR number and branch name from the prompt
|
||||
2. Read the PR and its review comments from Gitea: `tea pr <number> --comments -r <owner/repo>`
|
||||
3. Read the existing issue for full context: `tea issues <number> -r <owner/repo>`
|
||||
4. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
5. Produce a TDD test spec that ONLY covers the changes requested in the review — do NOT re-spec already-implemented features
|
||||
6. The test spec is stored in CAS automatically by the uwf pipeline (agents do not need to call `ocas put` directly)
|
||||
7. Find the existing worktree: `git worktree list` and locate the branch
|
||||
8. Output **$status=continue** with plan hash, repoPath, branch name, and worktree path
|
||||
|
||||
**Mode C — Bounced back by tester (fix_spec):**
|
||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||
2. Revise the test spec accordingly
|
||||
3. The test spec is stored in CAS automatically by the uwf pipeline (agents do not need to call `ocas put` directly)
|
||||
4. Output **$status=ready** with plan hash and repoPath
|
||||
|
||||
After producing the test spec:
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
2. Put the hash in frontmatter.plan (required when $status=ready)
|
||||
3. Set repoPath to the absolute path of the repository root
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||
IMPORTANT: Extract the repo remote (owner/repo) from git:
|
||||
```bash
|
||||
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
|
||||
```
|
||||
Store the result as repoRemote in your frontmatter output so downstream roles can use it.
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (fresh), continue (existing PR), or insufficient_info."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "continue" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, plan, repoPath, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
required: [$status]
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
developer:
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
@@ -50,33 +80,47 @@ roles:
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. First time (no existing branch):
|
||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
4. If bounced back from reviewer or tester (branch already exists):
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && pnpm install`
|
||||
4. If continuing on existing branch (prompt says "Continue work on existing branch" or provides a worktree path):
|
||||
- cd directly into the worktree path provided in the prompt
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
- Do NOT create a new branch or worktree
|
||||
5. If bounced back from reviewer or tester (branch already exists but no explicit worktree path):
|
||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
6. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
6. Read the test spec from CAS: `ocas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||
8. Write tests first based on the spec
|
||||
9. Implement the code to make tests pass
|
||||
10. Ensure `bun run build` passes with no errors
|
||||
11. Run `bun test` to verify all tests pass
|
||||
10. Ensure `pnpm run build` passes with no errors
|
||||
11. Run `pnpm test` to verify all tests pass
|
||||
|
||||
After implementation, before reporting done:
|
||||
12. Add a changeset file (`.changeset/<short-slug>.md`) with correct bump type:
|
||||
- `patch` for bug fixes, internal refactors, test-only changes
|
||||
- `minor` for new features, new CLI commands, new API surfaces
|
||||
- `major` for breaking changes
|
||||
List every affected package in the changeset frontmatter.
|
||||
13. Update documentation if the change affects user-facing behavior:
|
||||
- `README.md` — usage examples, feature descriptions
|
||||
- `.cards/` — architecture decision records (if applicable)
|
||||
- CLI prompt subcommand output (if CLI help text changes)
|
||||
- CLI `--help` text (if flags/commands are added or changed)
|
||||
|
||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
||||
or repeated attempts fail), set $status=failed with a reason.
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
@@ -95,8 +139,8 @@ roles:
|
||||
|
||||
Then perform code review:
|
||||
Hard checks (must all pass):
|
||||
3. `bun run build` — no build errors
|
||||
4. `bunx biome check` — no lint violations
|
||||
3. `pnpm run build` — no build errors
|
||||
4. `pnpm run check` — no lint violations
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
|
||||
@@ -104,19 +148,25 @@ roles:
|
||||
- No `console.log` in production code
|
||||
- No dynamic imports in production code
|
||||
|
||||
Documentation & changeset checks:
|
||||
6. Changeset exists in `.changeset/` with correct bump type (`patch`/`minor`/`major`) and lists all affected packages
|
||||
7. If the change is user-facing, documentation is updated:
|
||||
- `README.md` reflects new/changed behavior
|
||||
- `.cards/` architecture cards updated if design decisions changed
|
||||
- CLI prompt subcommand output updated (if it generates skill/reference content)
|
||||
- CLI `--help` text matches new flags/commands
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
@@ -129,8 +179,8 @@ roles:
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
1. Run `pnpm test` for automated test verification
|
||||
2. Read the test spec from CAS: `ocas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
@@ -139,19 +189,16 @@ roles:
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
@@ -178,22 +225,22 @@ roles:
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- type: object
|
||||
properties:
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
new: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
resume: { role: "planner", prompt: "Review the previous run output and continue the work." }
|
||||
planner:
|
||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||
insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
||||
continue: { role: "developer", prompt: "Continue work on existing branch {{{branch}}} at worktree {{{worktree}}}. Implement the revised TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Do NOT create a new branch or worktree — cd into the existing worktree and work there." }
|
||||
developer:
|
||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
|
||||
@@ -14,20 +14,21 @@ Monorepo with 3 packages under `packages/`:
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Bun
|
||||
- **Runtime:** Node.js
|
||||
- **Language:** TypeScript (strict mode, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
|
||||
- **Build:** `tsc --build` (composite project references)
|
||||
- **Test:** `bun test`
|
||||
- **Build:** `tsc` (composite project references, sequential: core → fs → cli)
|
||||
- **Test:** Vitest (`npx vitest run`)
|
||||
- **Package Manager:** pnpm (workspace)
|
||||
- **Lint/Format:** Biome (`biome check .` / `biome format --write .`)
|
||||
- **Publish:** Changesets + `bun publish` → npmjs (`@ocas/*`)
|
||||
- **Publish:** @shazhou/proman (`proman bump` + `proman publish`)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun test # Run all tests
|
||||
bun run build # Build all packages
|
||||
bun run check # Biome lint
|
||||
bun run format # Biome format (auto-fix)
|
||||
pnpm run test # Run all tests (vitest)
|
||||
pnpm run build # Build all packages (tsc via proman)
|
||||
pnpm run check # Biome lint
|
||||
pnpm run format # Biome format (auto-fix)
|
||||
```
|
||||
|
||||
## Code Conventions
|
||||
@@ -58,8 +59,7 @@ bun run format # Biome format (auto-fix)
|
||||
|
||||
- `Hash` — 13-character uppercase Crockford Base32 string (XXH64)
|
||||
- `CasNode` — content-addressed node with schema
|
||||
- `Store` — abstract storage interface (get/put)
|
||||
- `VariableStore` — SQLite-backed mutable bindings (name → hash)
|
||||
- `Store` — unified storage interface `{ cas: CasStore, var: VarStore, tag: TagStore }`
|
||||
- `ListOptions` — sorting/pagination options (`sort`, `desc`, `limit`, `offset`)
|
||||
- `ListEntry` — list result entry (`hash`, `created`, `updated`)
|
||||
|
||||
@@ -69,11 +69,11 @@ bun run format # Biome format (auto-fix)
|
||||
|
||||
### 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.).
|
||||
- **No "alias" concept** — every name resolution flows through `store.var`. Builtin schemas (`@ocas/schema`, `@ocas/string`, `@ocas/output/*`, …) are registered as variables during `bootstrap(store)`, alongside user-defined variables created via `ocas var set`.
|
||||
- **`bootstrap(store)`** synchronously writes builtin name → hash bindings into the unified store; called automatically by `openStore()`.
|
||||
- **`resolveHash(input, store)`** is the unified hash/name resolver in the CLI. If `input` matches the 13-char hash format it is returned as-is; otherwise `store.var` is queried by exact name. This means every CLI command that accepts a hash argument also accepts a variable name (schema names, user vars, etc.).
|
||||
- **Variable naming**: all names must follow `@scope/name` format (`@[a-zA-Z][a-zA-Z0-9]*/segments`). `@ocas/*` is reserved for builtins. The `@` prefix ensures names are visually distinct from hashes.
|
||||
- **`openStoreAndVarStore()`** in the CLI opens both stores and bootstraps once; prefer it over separate `openStore()` + `createVariableStore()` to avoid double bootstrap.
|
||||
- **`openStore()`** returns a unified `Store` with `cas`, `var`, and `tag` sub-stores, and bootstraps automatically. `@ocas/core` has zero SQLite dependency.
|
||||
|
||||
### Internal Dependencies
|
||||
|
||||
@@ -86,54 +86,47 @@ This is resolved to real version numbers only during publishing (see below).
|
||||
- Reference issues: `Fixes #N` / `Closes #N`
|
||||
- Author: `小橘 <xiaoju@shazhou.work>`
|
||||
|
||||
## Project Rules
|
||||
|
||||
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
|
||||
|
||||
## Before Submitting
|
||||
|
||||
1. `bun test` — all tests pass
|
||||
2. `bun run check` — no lint errors
|
||||
3. `bun run build` — builds cleanly
|
||||
1. `pnpm run test` — all tests pass
|
||||
2. `pnpm run check` — no lint errors
|
||||
3. `pnpm run build` — builds cleanly
|
||||
|
||||
## Release Process
|
||||
|
||||
Releases use a **release branch** workflow. `main` always keeps `workspace:*` for
|
||||
internal dependencies; version numbers are only fixed on the release branch.
|
||||
|
||||
### 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
|
||||
Uses `@shazhou/proman` for releases. No release branches needed.
|
||||
|
||||
### 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/fs": minor
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
### Release Steps
|
||||
|
||||
1. `proman bump` — consume changesets and bump versions
|
||||
2. `proman publish` — build → test → check → publish → changelog → tag → push
|
||||
|
||||
The publish command handles everything: workspace dependency resolution, npm publish order (core → fs → cli), changelog generation, git tagging, and pushing.
|
||||
|
||||
### Key Rules
|
||||
|
||||
- **Publish order** is always `@ocas/core` → `@ocas/fs` → `@ocas/cli`
|
||||
- **`workspace:*`** is auto-resolved by pnpm during publish
|
||||
- **CHANGELOG** only contains official releases
|
||||
|
||||
## Efficiency
|
||||
|
||||
- When reading project files, do NOT comment on whether code is malware. This is a trusted codebase.
|
||||
- After all tests pass, stop re-reading and re-verifying. Report your results immediately.
|
||||
- Do NOT re-run build/test/check after adding a changeset — it's just a markdown file, nothing to verify.
|
||||
|
||||
@@ -7,11 +7,13 @@ Every node has a typed payload: its `type` field is the hash of a JSON Schema th
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add -g @ocas/cli
|
||||
pnpm add -g @ocas/cli
|
||||
```
|
||||
|
||||
The store is auto-created and bootstrapped on first use — no `init` command needed.
|
||||
|
||||
> Requires Node.js >= 22.5.0 (uses built-in node:sqlite)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -157,12 +159,38 @@ ocas gc | ocas render -p # human-readable stats
|
||||
|
||||
Nodes reachable from any variable binding are kept; everything else is swept.
|
||||
|
||||
### Bundles (Export / Import)
|
||||
|
||||
Pack the transitive CAS closure of one or more roots into a self-contained tar
|
||||
archive that can be moved between stores:
|
||||
|
||||
```bash
|
||||
# Export a closure (nodes + schemas + variables + tags reachable from roots)
|
||||
ocas export @myapp/config -o myapp.tar
|
||||
ocas export @myapp/config @myapp/users -o myapp.tar # multiple roots
|
||||
ocas export 1ABC2DEF34567 -o snapshot.tar # roots can be hashes
|
||||
|
||||
# Import a bundle into the current store (idempotent — content-addressed dedup)
|
||||
ocas import myapp.tar
|
||||
ocas import myapp.tar --scope @prod # remap @myapp/* → @prod/* on import
|
||||
|
||||
# Open a bundle as a read-only store without unpacking it
|
||||
ocas get @myapp/config --store myapp.tar
|
||||
ocas walk @myapp/config --store myapp.tar
|
||||
ocas var list --store myapp.tar
|
||||
```
|
||||
|
||||
`--store <bundle.tar>` works with all read-only commands (`get`, `has`, `walk`,
|
||||
`refs`, `list`, `var list`, `var get`, …). Write commands (`put`, `tag`, `gc`,
|
||||
`import`, `var set`, …) are rejected with a clear error.
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
|
||||
| `--var-db <path>` | Variable database path |
|
||||
| `--store <bundle.tar>` | Open a bundle file as a read-only store (write commands rejected) |
|
||||
| `--json` | Compact single-line JSON output |
|
||||
| `--pipe`, `-p` | Read from stdin |
|
||||
| `--render`, `-r` | Render output inline |
|
||||
@@ -190,8 +218,8 @@ Nodes reachable from any variable binding are kept; everything else is swept.
|
||||
## Using as a Library
|
||||
|
||||
```bash
|
||||
bun add @ocas/core # in-memory store
|
||||
bun add @ocas/core @ocas/fs # + filesystem persistence
|
||||
pnpm add @ocas/core # in-memory store
|
||||
pnpm add @ocas/core @ocas/fs # + filesystem persistence
|
||||
```
|
||||
|
||||
```typescript
|
||||
@@ -214,9 +242,9 @@ const node = store.get(hash);
|
||||
For filesystem persistence, use `@ocas/fs`:
|
||||
|
||||
```typescript
|
||||
import { openStoreAndVarStore } from "@ocas/fs";
|
||||
import { openStore } from "@ocas/fs";
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore("/path/to/store");
|
||||
const store = await openStore("/path/to/store");
|
||||
```
|
||||
|
||||
See individual package READMEs for full API docs:
|
||||
@@ -228,11 +256,11 @@ See individual package READMEs for full API docs:
|
||||
|
||||
```bash
|
||||
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
|
||||
pnpm install
|
||||
pnpm run build # tsc --build
|
||||
pnpm test # run all tests
|
||||
pnpm run check # biome lint
|
||||
pnpm run format # biome format
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@uncaged/json-cas-workspace",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.0.0",
|
||||
"@changesets/changelog-github": "^0.7.0",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"bun-types": "^1.3.14",
|
||||
"typescript": "^5.8.0",
|
||||
"ulidx": "^2.4.1",
|
||||
},
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@ocas/cli",
|
||||
"version": "0.6.0",
|
||||
"bin": {
|
||||
"ocas": "src/index.ts",
|
||||
},
|
||||
"dependencies": {
|
||||
"@ocas/core": "^0.6.0",
|
||||
"@ocas/fs": "^0.6.0",
|
||||
},
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@ocas/core",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
"liquidjs": "^10.27.0",
|
||||
"xxhash-wasm": "^1.1.0",
|
||||
},
|
||||
},
|
||||
"packages/fs": {
|
||||
"name": "@ocas/fs",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@ocas/core": "^0.6.0",
|
||||
"cborg": "^4.2.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="],
|
||||
|
||||
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="],
|
||||
|
||||
"@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="],
|
||||
|
||||
"@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="],
|
||||
|
||||
"@changesets/changelog-github": ["@changesets/changelog-github@0.7.0", "", { "dependencies": { "@changesets/get-github-info": "^0.8.0", "@changesets/types": "^6.1.0", "dotenv": "^8.1.0" } }, "sha512-rBsbRvc4TVn+FvFnOVM3LxlFJfTXXCp8gfVJ+0BubxWNSVnLuAzowi5j+IEraLLP52w8AAs9QfKbPS3MMiXQJA=="],
|
||||
|
||||
"@changesets/cli": ["@changesets/cli@2.31.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.1", "@changesets/assemble-release-plan": "^6.0.10", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.4", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/get-release-plan": "^4.0.16", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg=="],
|
||||
|
||||
"@changesets/config": ["@changesets/config@3.1.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q=="],
|
||||
|
||||
"@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "^0.1.5" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="],
|
||||
|
||||
"@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.4", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg=="],
|
||||
|
||||
"@changesets/get-github-info": ["@changesets/get-github-info@0.8.0", "", { "dependencies": { "dataloader": "^1.4.0", "node-fetch": "^2.5.0" } }, "sha512-cRnC+xdF0JIik7coko3iUP9qbnfi1iJQ3sAa6dE+Tx3+ET8bjFEm63PA4WEohgjYcmsOikPHWzPsMWWiZmntOQ=="],
|
||||
|
||||
"@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.16", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.10", "@changesets/config": "^3.1.4", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g=="],
|
||||
|
||||
"@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="],
|
||||
|
||||
"@changesets/git": ["@changesets/git@3.0.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@manypkg/get-packages": "^1.1.3", "is-subdir": "^1.1.1", "micromatch": "^4.0.8", "spawndamnit": "^3.0.1" } }, "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw=="],
|
||||
|
||||
"@changesets/logger": ["@changesets/logger@0.1.1", "", { "dependencies": { "picocolors": "^1.1.0" } }, "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg=="],
|
||||
|
||||
"@changesets/parse": ["@changesets/parse@0.4.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "js-yaml": "^4.1.1" } }, "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A=="],
|
||||
|
||||
"@changesets/pre": ["@changesets/pre@2.0.2", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1" } }, "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug=="],
|
||||
|
||||
"@changesets/read": ["@changesets/read@0.6.7", "", { "dependencies": { "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/parse": "^0.4.3", "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "p-filter": "^2.1.0", "picocolors": "^1.1.0" } }, "sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA=="],
|
||||
|
||||
"@changesets/should-skip-package": ["@changesets/should-skip-package@0.1.2", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw=="],
|
||||
|
||||
"@changesets/types": ["@changesets/types@6.1.0", "", {}, "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA=="],
|
||||
|
||||
"@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="],
|
||||
|
||||
"@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="],
|
||||
|
||||
"@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="],
|
||||
|
||||
"@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@ocas/cli": ["@ocas/cli@workspace:packages/cli"],
|
||||
|
||||
"@ocas/core": ["@ocas/core@workspace:packages/core"],
|
||||
|
||||
"@ocas/fs": ["@ocas/fs@workspace:packages/fs"],
|
||||
|
||||
"@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
|
||||
|
||||
"better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"cborg": ["cborg@4.5.8", "", { "bin": { "cborg": "lib/bin.js" } }, "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw=="],
|
||||
|
||||
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
|
||||
|
||||
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="],
|
||||
|
||||
"detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="],
|
||||
|
||||
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
|
||||
|
||||
"dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="],
|
||||
|
||||
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="],
|
||||
|
||||
"is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
|
||||
|
||||
"liquidjs": ["liquidjs@10.27.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w=="],
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="],
|
||||
|
||||
"p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="],
|
||||
|
||||
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
|
||||
|
||||
"prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||
|
||||
"spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ulidx": ["ulidx@2.4.1", "", { "dependencies": { "layerr": "^3.0.0" } }, "sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
|
||||
|
||||
"@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="],
|
||||
|
||||
"@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"@manypkg/get-packages/@changesets/types": ["@changesets/types@4.1.0", "", {}, "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw=="],
|
||||
|
||||
"@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# Sync README
|
||||
|
||||
When updating README.md files in this monorepo, follow these conventions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Root `README.md` — project overview and navigation hub
|
||||
- Per-package `packages/*/README.md` — each package self-contained
|
||||
|
||||
## Root README Structure
|
||||
|
||||
The root README should have these sections in order:
|
||||
|
||||
1. **Title and one-liner** — content-addressed storage for JSON with schema validation
|
||||
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
||||
3. **Architecture** — dependency layer diagram (text-based)
|
||||
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib)
|
||||
5. **Quick Start** — install, build, basic usage
|
||||
6. **CLI Reference** — brief command list, detailed usage in cli README
|
||||
7. **Development** — bun install / build / check / test
|
||||
8. **Publishing** — changeset workflow (bun run release)
|
||||
|
||||
## Per-Package README Structure
|
||||
|
||||
Each package README should have:
|
||||
|
||||
1. **Title** — package name
|
||||
2. **One-line description** — matching package.json
|
||||
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
||||
4. **Installation** — bun add (for libs) or "included as binary" (for cli)
|
||||
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
||||
6. **CLI Usage** (cli packages) — command reference with examples
|
||||
7. **Internal Structure** — brief src/ file organization
|
||||
8. **Configuration** (if applicable)
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Gather current state
|
||||
For each package read:
|
||||
- package.json (name, version, description, dependencies, bin)
|
||||
- src/index.ts (public API exports)
|
||||
- Existing README.md (preserve hand-written content worth keeping)
|
||||
|
||||
### Step 2: Update root README
|
||||
- Ensure ALL packages in packages/ directory are listed in the table
|
||||
- Update CLI command reference from actual --help output
|
||||
- Keep Quick Start examples valid
|
||||
|
||||
### Step 3: Write/update each package README
|
||||
- Follow the per-package structure
|
||||
- API section MUST match actual src/index.ts exports — never invent
|
||||
- For cli packages: document CLI binary name, how it is invoked
|
||||
- For lib packages: document exported types and functions
|
||||
- Internal structure: list actual files in src/
|
||||
|
||||
### Step 4: Verify
|
||||
- All relative links work
|
||||
- Package names match package.json
|
||||
- No references to removed/renamed packages
|
||||
- bun run build still passes
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only document what src/index.ts actually exports
|
||||
- Root README summarizes, package READMEs go into detail
|
||||
- Verify CLI examples against actual commands
|
||||
- Preserve existing good prose when updating
|
||||
- English for all README content
|
||||
+15
-14
@@ -1,29 +1,30 @@
|
||||
{
|
||||
"name": "@ocas/workspace",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.0.0",
|
||||
"@changesets/changelog-github": "^0.7.0",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"bun-types": "^1.3.14",
|
||||
"typescript": "^5.8.0",
|
||||
"ulidx": "^2.4.1"
|
||||
"@biomejs/biome": "^2.4.16",
|
||||
"@shazhou/proman": "^0.6.4",
|
||||
"@types/node": "^25.9.1",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"ulidx": "^2.4.1",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --build packages/core packages/fs",
|
||||
"test": "bun test",
|
||||
"check": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"release": "changeset version && bun run build && changeset publish"
|
||||
"build": "proman build",
|
||||
"test": "proman test",
|
||||
"check": "proman check",
|
||||
"format": "proman format"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/ocas.git"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/ocas",
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
}
|
||||
|
||||
+32
-42
@@ -1,63 +1,53 @@
|
||||
# @uncaged/cli-json-cas
|
||||
# @ocas/cli
|
||||
|
||||
## 0.6.0
|
||||
## 0.4.0 — 2026-06-07
|
||||
|
||||
### Minor Changes
|
||||
- New `ocas export <root> [<root>...] -o <bundle.tar>` — export CAS closures as self-contained tar bundles.
|
||||
- New `ocas import <bundle.tar> [--scope @new]` — import bundles into a store.
|
||||
- New global `--store <bundle.tar>` flag — open a bundle as read-only store for inspection commands.
|
||||
- Rename `ocas prompt setup` to `ocas prompt bootstrap` with programmatic generation.
|
||||
- New `ocas prompt list` subcommand.
|
||||
|
||||
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||
## 0.3.1 — 2026-06-04
|
||||
|
||||
### Breaking Changes
|
||||
- Fix prompt docs: `bun` → `pnpm` install instructions, remove stale `--var-db` flag.
|
||||
|
||||
- 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
|
||||
## 0.3.0 — 2026-06-03
|
||||
|
||||
### New Features
|
||||
- No CLI-specific changes. Coordinated version bump with `@ocas/fs` 0.3.0.
|
||||
|
||||
- 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.2.2 — 2026-06-03
|
||||
|
||||
### Patch Changes
|
||||
- Lint and format fixes.
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.6.0
|
||||
- @uncaged/json-cas-fs@0.6.0
|
||||
## 0.2.1 — 2026-06-03
|
||||
|
||||
## 0.5.3
|
||||
- Full CLI build support with `tsc` emit + Node compatibility.
|
||||
- Migrate runtime from Bun to Node.js + pnpm.
|
||||
|
||||
### Patch Changes
|
||||
## 0.2.0 — 2026-06-02
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.5.3
|
||||
- @uncaged/json-cas-fs@0.5.3
|
||||
### Breaking Changes
|
||||
|
||||
## 0.3.0
|
||||
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead.
|
||||
|
||||
### Patch Changes
|
||||
### New Features
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.3.0
|
||||
- @uncaged/json-cas-fs@0.3.0
|
||||
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands.
|
||||
- `ocas get` and `ocas var get` now include tag info in output.
|
||||
- `ocas list --tag` and `ocas var list --tag` filter support.
|
||||
|
||||
## 0.2.0
|
||||
## 0.1.2 — 2026-06-02
|
||||
|
||||
### Patch Changes
|
||||
- Fix render output missing trailing newline.
|
||||
- Add agent skill setup hint with version to help output.
|
||||
- Remove postinstall script.
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.2.0
|
||||
- @uncaged/json-cas-fs@0.2.0
|
||||
## 0.1.1 — 2026-06-02
|
||||
|
||||
## 0.1.3
|
||||
- Add `ocas prompt usage` and `ocas prompt setup` commands.
|
||||
- Add `--version` flag.
|
||||
|
||||
### Patch Changes
|
||||
## 0.1.0 — 2026-06-01
|
||||
|
||||
- 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. CLI tool for OCAS with put, get, list, render, verify, gc, var, and template commands.
|
||||
|
||||
+42
-10
@@ -6,7 +6,7 @@ CLI tool for ocas stores.
|
||||
|
||||
`@ocas/cli` provides the `ocas` command for managing a filesystem-backed store: node CRUD, integrity checks, reference listing, graph walks, variables, and output templates. It uses `@ocas/fs` for persistence and `@ocas/core` for core operations.
|
||||
|
||||
The store is **auto-created and bootstrapped** on first use, so there is no `init`/`bootstrap` command. Schemas are ordinary `@schema`-typed nodes — register one with `ocas put @schema file.json` and list them with `ocas list --type @schema`; there is no dedicated `schema` subcommand.
|
||||
The store is **auto-created and bootstrapped** on first use, so there is no `init`/`bootstrap` command. Schemas are ordinary `@ocas/schema`-typed nodes — register one with `ocas put @ocas/schema file.json` and list them with `ocas list --type @ocas/schema`; there is no dedicated `schema` subcommand.
|
||||
|
||||
**Dependencies:** `@ocas/core`, `@ocas/fs`
|
||||
|
||||
@@ -15,17 +15,17 @@ The store is **auto-created and bootstrapped** on first use, so there is no `ini
|
||||
Published as an npm package with a binary entry:
|
||||
|
||||
```bash
|
||||
bun add -g @ocas/cli
|
||||
pnpm add -g @ocas/cli
|
||||
# or from the monorepo workspace:
|
||||
bun link
|
||||
pnpm link
|
||||
```
|
||||
|
||||
**Binary name:** `ocas` (points to `src/index.ts`, run with Bun).
|
||||
**Binary name:** `ocas` (points to `dist/index.js`, run with Node).
|
||||
|
||||
In development:
|
||||
|
||||
```bash
|
||||
bun packages/cli-ocas/src/index.ts <command> [args]
|
||||
node packages/cli/dist/index.js <command> [args]
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
@@ -40,6 +40,7 @@ Usage: ocas [--home <path>] [--json] <command> [args]
|
||||
|------|-------------|
|
||||
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
|
||||
| `--var-db <path>` | Variable database path (default: `<home>/variables.db`) |
|
||||
| `--store <bundle.tar>` | Open a bundle file as a read-only store (write commands rejected) |
|
||||
| `--json` | Compact (single-line) JSON output |
|
||||
|
||||
### Envelope format
|
||||
@@ -82,12 +83,14 @@ raw, non-envelope text.
|
||||
| `template list` | `{ schemaHash, contentHash }[]` | `@output/template-list` |
|
||||
| `template delete <schema-hash>` | `{ deleted: boolean }` | `@output/template-delete` |
|
||||
| `gc` | `{ total, reachable, collected, scanned }` | `@output/gc` |
|
||||
| `export <root...> -o <bundle.tar>` | `{ nodes, vars, tags }` | `@output/export` |
|
||||
| `import <bundle.tar> [--scope @new]` | nested `{ nodes, vars, tags }` stats | `@output/import` |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Register a schema (schemas are plain @schema nodes) and store a payload
|
||||
ocas put @schema ./schemas/item.json
|
||||
# Register a schema (schemas are plain @ocas/schema nodes) and store a payload
|
||||
ocas put @ocas/schema ./schemas/item.json
|
||||
# → { "type": "...", "value": "0123456789ABC" } (the schema's type hash)
|
||||
|
||||
ocas put 0123456789ABC ./payloads/item.json
|
||||
@@ -98,7 +101,7 @@ ocas verify <content-hash>
|
||||
ocas walk <content-hash> --format tree
|
||||
|
||||
# List every registered schema, then extract the hashes with jq
|
||||
ocas list --type @schema | jq -r '.value[]'
|
||||
ocas list --type @ocas/schema | jq -r '.value[]'
|
||||
```
|
||||
|
||||
### Pipe composition
|
||||
@@ -108,13 +111,13 @@ Because every command shares the `{ type, value }` envelope, output composes dir
|
||||
|
||||
```bash
|
||||
# put emits a cas_ref hash envelope; render -p dereferences and renders the node
|
||||
ocas put @schema ./schemas/item.json | ocas render -p
|
||||
ocas put @ocas/schema ./schemas/item.json | ocas render -p
|
||||
|
||||
# render gc statistics
|
||||
ocas gc | ocas render -p
|
||||
|
||||
# render every schema referenced by a list result
|
||||
ocas list --type @schema | ocas render -p
|
||||
ocas list --type @ocas/schema | ocas render -p
|
||||
```
|
||||
|
||||
### Variable names as hash arguments
|
||||
@@ -143,6 +146,35 @@ ocas render <content-hash>
|
||||
# → Item: Widget
|
||||
```
|
||||
|
||||
### Bundles (export / import)
|
||||
|
||||
`ocas export` walks the transitive CAS closure (refs **and** schema chains) of one or more
|
||||
roots and writes a self-contained POSIX-tar archive containing every reachable CAS node
|
||||
(`cas/<hash>.bin`, CBOR-encoded), every variable whose value is in-closure
|
||||
(`vars.jsonl`), and every tag attached to an in-closure target (`tags.jsonl`).
|
||||
|
||||
```bash
|
||||
ocas export @myapp/config -o myapp.tar # single root by name
|
||||
ocas export @myapp/config @myapp/users -o m.tar # multiple roots
|
||||
ocas export 1ABC2DEF34567 -o snapshot.tar # raw hash root
|
||||
|
||||
# Import into the current store (idempotent — content-addressed dedup)
|
||||
ocas import myapp.tar
|
||||
ocas import myapp.tar --scope @prod # remap @myapp/* → @prod/*
|
||||
|
||||
# Inspect a bundle without unpacking it
|
||||
ocas get @myapp/config --store myapp.tar
|
||||
ocas walk @myapp/config --store myapp.tar
|
||||
ocas var list --store myapp.tar
|
||||
```
|
||||
|
||||
`-o <path>` (required for `export`) names the output tar. `--scope @new` rewrites the
|
||||
leading `@scope` of every imported variable name except builtins (`@ocas/*`).
|
||||
`--store <bundle.tar>` swaps the store backend for any read-only command (`get`, `has`,
|
||||
`refs`, `walk`, `list`, `var list`, `var get`, `verify`, `render`, …); write commands
|
||||
(`put`, `tag`, `gc`, `import`, `var set`, `template set`, …) refuse with
|
||||
`--store is read-only` when the flag is set.
|
||||
|
||||
## Internal Structure
|
||||
|
||||
| File | Purpose |
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
{
|
||||
"name": "@ocas/cli",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"description": "CLI for OCAS content-addressed store",
|
||||
"keywords": [
|
||||
"cas",
|
||||
"cli",
|
||||
"content-addressing"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ocas": "src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
|
||||
"ocas": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"prompts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@ocas/core": "0.1.0",
|
||||
"@ocas/fs": "0.1.0"
|
||||
"@ocas/core": "workspace:*",
|
||||
"@ocas/fs": "workspace:*"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,5 +30,8 @@
|
||||
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/cli",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# 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:** `pnpm 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 tag @myapp/config status:active # add tag/label to a target
|
||||
ocas untag @myapp/config status # remove tag/label by key
|
||||
```
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
### Bundles (Export / Import)
|
||||
|
||||
```bash
|
||||
ocas export <root>... -o <bundle.tar> # write closure of roots to tar
|
||||
ocas export @myapp/config -o myapp.tar
|
||||
ocas export @myapp/config @myapp/users -o m.tar # multiple roots
|
||||
ocas import <bundle.tar> # import bundle (idempotent)
|
||||
ocas import <bundle.tar> --scope @prod # remap @<scope>/* → @prod/*
|
||||
ocas get <hash> --store <bundle.tar> # read-only access into bundle
|
||||
```
|
||||
|
||||
`export` walks refs **and** schema chains; the resulting tar contains every reachable
|
||||
CAS node (`cas/<hash>.bin`, CBOR), every variable whose value is in-closure, and every
|
||||
tag attached to an in-closure target. `import` is content-addressed (deduplicates
|
||||
existing nodes). `--scope @new` rewrites the leading `@scope` of imported variable
|
||||
names except `@ocas/*` builtins. `--store <bundle.tar>` opens a bundle as a read-only
|
||||
store for any inspection command; write commands (`put`, `tag`, `gc`, `import`,
|
||||
`var set`, …) refuse with `--store is read-only`.
|
||||
|
||||
### Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
|
||||
| `--store <bundle.tar>` | Open a bundle as a read-only store (write commands rejected) |
|
||||
| `--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 |
|
||||
| `-o <path>` | Output path (used by `export`) |
|
||||
| `--scope @new` | Variable scope remap (used by `import`) |
|
||||
|
||||
## 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 { openStore } from "@ocas/fs";
|
||||
const store = await openStore("/path/to/store");
|
||||
// store.cas / store.var / store.tag
|
||||
```
|
||||
|
||||
## 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.
|
||||
+516
-384
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Walk up from __dirname to find the nearest @ocas/cli package.json
|
||||
function _findCliVersion(): string {
|
||||
let dir = dirname(fileURLToPath(import.meta.url));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = join(dir, "package.json");
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
if (pkg.name === "@ocas/cli") {
|
||||
return pkg.version ?? "0.0.0";
|
||||
}
|
||||
} catch {
|
||||
// not found, keep walking
|
||||
}
|
||||
dir = dirname(dir);
|
||||
}
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
const CLI_VERSION = _findCliVersion();
|
||||
|
||||
export function cmdPromptBootstrap(): string {
|
||||
return `# ocas Bootstrap
|
||||
|
||||
Set up or upgrade ocas (Object Content Addressable Store) — from zero to running your first command.
|
||||
|
||||
## Scenario A: Fresh Install
|
||||
|
||||
### Step 0 — Environment pre-flight check
|
||||
|
||||
Run ALL checks below BEFORE installing anything. If any check fails, fix it first — do NOT skip ahead.
|
||||
|
||||
\`\`\`bash
|
||||
# 1. Node.js
|
||||
node --version # need v20+
|
||||
# FIX: install via nvm (https://github.com/nvm-sh/nvm) or your OS package manager
|
||||
|
||||
# 2. Package manager — pnpm (preferred) or npm
|
||||
pnpm --version || npm --version
|
||||
# FIX for pnpm: corepack enable pnpm (Node 20+)
|
||||
# or: npm install -g pnpm
|
||||
# If pnpm is not an option, npm works too — adjust install commands below
|
||||
|
||||
# 3. Global bin directory is in PATH
|
||||
# For pnpm:
|
||||
pnpm bin -g # prints the global bin directory
|
||||
# For npm:
|
||||
npm prefix -g # global prefix; bin is <prefix>/bin
|
||||
# The directory printed above MUST be in your PATH.
|
||||
# Test: echo \\$PATH | tr ':' '\\n'
|
||||
# FIX: add the missing directory to your shell's startup file
|
||||
# (e.g. ~/.bashrc, ~/.zshrc, ~/.profile, or fish config):
|
||||
# export PATH="<global-bin-dir>:$PATH"
|
||||
\`\`\`
|
||||
|
||||
**All checks must pass before continuing.** If you had to modify PATH, verify the change persists by opening a new shell or sourcing your shell config.
|
||||
|
||||
### Step 1 — Install @ocas/cli
|
||||
|
||||
\`\`\`bash
|
||||
# Using pnpm (preferred)
|
||||
pnpm add -g @ocas/cli
|
||||
|
||||
# Using npm
|
||||
npm install -g @ocas/cli
|
||||
\`\`\`
|
||||
|
||||
**After installing, verify that \`ocas\` is available in PATH:**
|
||||
|
||||
\`\`\`bash
|
||||
ocas --version # should print ${CLI_VERSION}
|
||||
\`\`\`
|
||||
|
||||
If the command is not found, the global bin directory is not in the current shell's PATH. **You must fix this before continuing:**
|
||||
|
||||
1. Find where the binary was installed:
|
||||
\`\`\`bash
|
||||
find ~/.local /usr/local -name ocas -type f 2>/dev/null
|
||||
npm prefix -g # global prefix — bin is <prefix>/bin
|
||||
\`\`\`
|
||||
2. Add the directory to PATH permanently by appending to the user's shell config (e.g. \`~/.bashrc\`, \`~/.zshrc\`, \`~/.profile\`, or fish config):
|
||||
\`\`\`bash
|
||||
export PATH="<global-bin-dir>:$PATH"
|
||||
\`\`\`
|
||||
3. Source the updated config or open a new shell, then re-verify the command works.
|
||||
|
||||
### Step 2 — Verify version
|
||||
|
||||
\`\`\`bash
|
||||
ocas --version # should print ${CLI_VERSION}
|
||||
\`\`\`
|
||||
|
||||
### Step 3 — Install the ocas skill
|
||||
|
||||
Run the usage prompt and save it as a skill so your agent knows how to use ocas:
|
||||
|
||||
\`\`\`bash
|
||||
ocas prompt usage
|
||||
\`\`\`
|
||||
|
||||
The output is the skill body **without** frontmatter. Save it as a skill in your agent framework with the following YAML frontmatter prepended:
|
||||
|
||||
\`\`\`yaml
|
||||
---
|
||||
name: ocas
|
||||
description: "Use when storing, retrieving, or managing typed JSON data with OCAS (Object Content Addressable Store). Covers CLI usage, variable management, templates, rendering, and pipe composition."
|
||||
version: ${CLI_VERSION}
|
||||
author: OCAS
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [cas, storage, cli, json, schema, content-addressing]
|
||||
---
|
||||
\`\`\`
|
||||
|
||||
Replace the \`version\` field with the installed CLI version (from \`ocas --version\`).
|
||||
|
||||
**⚠ After saving the skill, start a new session** so the agent loads the updated skill content. Skills saved in the current session are not active until the next session.
|
||||
|
||||
### Step 4 — End-to-end verify
|
||||
|
||||
\`\`\`bash
|
||||
# Store a string value
|
||||
ocas put @ocas/string '"hello world"'
|
||||
|
||||
# Get it back using the returned hash
|
||||
ocas get <hash>
|
||||
\`\`\`
|
||||
|
||||
If \`ocas get\` returns \`"hello world"\`, the setup is working.
|
||||
|
||||
## Scenario B: Upgrade from Previous Version
|
||||
|
||||
### Step 1 — Update to latest
|
||||
|
||||
\`\`\`bash
|
||||
# Using pnpm
|
||||
pnpm add -g @ocas/cli@latest
|
||||
|
||||
# Using npm
|
||||
npm install -g @ocas/cli@latest
|
||||
\`\`\`
|
||||
|
||||
### Step 2 — Verify version
|
||||
|
||||
\`\`\`bash
|
||||
ocas --version # should print ${CLI_VERSION}
|
||||
\`\`\`
|
||||
|
||||
### Step 3 — Regenerate skill
|
||||
|
||||
Skill content is bundled with the CLI — always regenerate after upgrading:
|
||||
|
||||
\`\`\`bash
|
||||
ocas prompt usage # → update skill "ocas"
|
||||
\`\`\`
|
||||
|
||||
Update the \`version\` field in the skill frontmatter to match the new CLI version.
|
||||
|
||||
**⚠ After updating the skill, start a new session** to load the new skill content.
|
||||
|
||||
### Step 4 — Verify
|
||||
|
||||
\`\`\`bash
|
||||
ocas put @ocas/string '"upgrade test"'
|
||||
ocas get <hash>
|
||||
\`\`\`
|
||||
|
||||
## Available prompts
|
||||
|
||||
\`\`\`bash
|
||||
ocas prompt list # list available prompt names
|
||||
ocas prompt usage # CLI usage guide (skill body)
|
||||
ocas prompt bootstrap # this guide
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
@@ -1,58 +1,6 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
|
||||
|
||||
exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: ocas var set <name> <hash> [--tag <tag>...]"`;
|
||||
|
||||
exports[`Phase 7: Edge Cases 7.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]
|
||||
|
||||
All JSON commands emit a { type, value } envelope. The type is the hash of the
|
||||
command's @ocas/output/* schema (shown in parentheses); pipe any envelope into
|
||||
\`render -p\` to render its value (ocas_ref hashes are expanded).
|
||||
|
||||
Commands:
|
||||
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@ocas/output/put)
|
||||
get <hash> Print node as envelope (@ocas/output/get)
|
||||
has <hash> Print envelope (value=boolean) (@ocas/output/has)
|
||||
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@ocas/output/verify)
|
||||
refs <hash> List direct ocas_ref edges (@ocas/output/refs)
|
||||
walk <hash> [--format tree] Recursive traversal (@ocas/output/walk)
|
||||
hash <type-hash> <file.json|--pipe> Compute hash without storing (@ocas/output/hash)
|
||||
render <hash> [options] Render node as text with resolution decay (raw output)
|
||||
render --pipe/-p [options] Render { type, value } from stdin (raw output)
|
||||
list --type <hash-or-name> List hashes for a type (value=string[]) (@ocas/output/list)
|
||||
list-meta List meta-schema hashes (value=string[]) (@ocas/output/list-meta)
|
||||
list-schema List all schema hashes (value=string[]) (@ocas/output/list-schema)
|
||||
var set <name> <hash> [--tag <tag>...] Create/update a variable (@ocas/output/var-set)
|
||||
var get <name> --schema <hash> Get a variable by name + schema (@ocas/output/var-get)
|
||||
var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete)
|
||||
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)
|
||||
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)
|
||||
template delete <schema-hash> Delete template for schema (@ocas/output/template-delete)
|
||||
gc Run garbage collection (@ocas/output/gc)
|
||||
|
||||
Flags:
|
||||
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
|
||||
--var-db <path> Variable database path (default: <store>/variables.db)
|
||||
--json Compact JSON output
|
||||
--render, -r Render output inline (equivalent to | ocas render -p)
|
||||
--schema <hash> Schema hash filter for var get/delete/tag/list
|
||||
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
|
||||
--inline <text> Inline text content for template set
|
||||
--resolution <n> Initial resolution for render (default: 1.0)
|
||||
--decay <n> Decay factor for render (default: 0.5)
|
||||
--epsilon <n> Cutoff threshold for render (default: 0.01)
|
||||
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)"
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||
exports[`Phase 3: Variable System > 3.1 var set creates variable 1`] = `
|
||||
{
|
||||
"type": "0Q5EMYK4SYSS9",
|
||||
"value": {
|
||||
@@ -65,9 +13,9 @@ exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
exports[`Phase 3: Variable System > 3.2 var get returns variable 1`] = `
|
||||
{
|
||||
"type": "7C75FQT98KKQD",
|
||||
"type": "F5RRJTXP8Z99D",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "@myapp/config",
|
||||
@@ -78,58 +26,16 @@ exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
exports[`Phase 3: Variable System > 3.3 var list shows all variables 1`] = `
|
||||
{
|
||||
"type": "AF0XACGXHPMC1",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/schema",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {},
|
||||
"value": "CTS5P6RD8HMCS",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/string",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "7VQ43ZSJTEWA7",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/number",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "BEAZQGKVXMZT8",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/integer",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "B26JM4PBHPAFK",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/boolean",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "1AVHCXEJVDCPP",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/bool",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "1AVHCXEJVDCPP",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/object",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "944RT37WX1PQ5",
|
||||
"value": "9W3MGR3184QYE",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
@@ -138,6 +44,27 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
"tags": {},
|
||||
"value": "D45CW047XS17Y",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/bool",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "1AVHCXEJVDCPP",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/boolean",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "1AVHCXEJVDCPP",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/integer",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "B26JM4PBHPAFK",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/null",
|
||||
@@ -147,17 +74,38 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/put",
|
||||
"name": "@ocas/number",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "4ZHWK21APCFZ5",
|
||||
"value": "BEAZQGKVXMZT8",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/object",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "944RT37WX1PQ5",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/export",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "3P2SFAVZXZ474",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/gc",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "7KHZTY010988K",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/get",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "FB4K0SXG68ZFS",
|
||||
"value": "7V5G8E2VW8B2G",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
@@ -175,24 +123,10 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/verify",
|
||||
"name": "@ocas/output/import",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "52HEFB52BD0GF",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/refs",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "2TKP4RGBJ4V43",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/walk",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "4HG6MD3XG5H5C",
|
||||
"value": "198WWJWDA6KDX",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
@@ -217,52 +151,31 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-set",
|
||||
"name": "@ocas/output/put",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "0Q5EMYK4SYSS9",
|
||||
"value": "4ZHWK21APCFZ5",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-get",
|
||||
"name": "@ocas/output/refs",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "7C75FQT98KKQD",
|
||||
"value": "2TKP4RGBJ4V43",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-delete",
|
||||
"name": "@ocas/output/tag",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "C3MYPR5RGQFZT",
|
||||
"value": "CPSWA9TB2JMWP",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-tag",
|
||||
"name": "@ocas/output/template-delete",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "9103EYRMM949A",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-list",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "AF0XACGXHPMC1",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-history",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "EVZJS80TRFKE1",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/template-set",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "BJDHPAE4Q8TXM",
|
||||
"value": "BY7BGZJND3N7R",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
@@ -280,30 +193,86 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/template-delete",
|
||||
"name": "@ocas/output/template-set",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "BY7BGZJND3N7R",
|
||||
"value": "BJDHPAE4Q8TXM",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/gc",
|
||||
"name": "@ocas/output/untag",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "7KHZTY010988K",
|
||||
"value": "BPEQMRQNJK80Z",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"name": "@ocas/output/var-delete",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "9W3MGR3184QYE",
|
||||
"value": "C3MYPR5RGQFZT",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-get",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "F5RRJTXP8Z99D",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-history",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "EVZJS80TRFKE1",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-list",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "AF0XACGXHPMC1",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-set",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "0Q5EMYK4SYSS9",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/verify",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "52HEFB52BD0GF",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/walk",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "4HG6MD3XG5H5C",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/schema",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "CTS5P6RD8HMCS",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/string",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "7VQ43ZSJTEWA7",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
exports[`Phase 3: Variable System > 3.4 var list prefix filters by prefix 1`] = `
|
||||
{
|
||||
"type": "AF0XACGXHPMC1",
|
||||
"value": [
|
||||
@@ -318,7 +287,7 @@ exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
|
||||
exports[`Phase 3: Variable System > 3.5 var set upsert updates existing variable 1`] = `
|
||||
{
|
||||
"type": "0Q5EMYK4SYSS9",
|
||||
"value": {
|
||||
@@ -331,9 +300,9 @@ exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
exports[`Phase 3: Variable System > 3.6 var set with tag and label adds them 1`] = `
|
||||
{
|
||||
"type": "9103EYRMM949A",
|
||||
"type": "0Q5EMYK4SYSS9",
|
||||
"value": {
|
||||
"labels": [
|
||||
"important",
|
||||
@@ -348,7 +317,7 @@ exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
|
||||
exports[`Phase 3: Variable System > 3.7 var list --tag env:prod filters by kv tag 1`] = `
|
||||
{
|
||||
"type": "AF0XACGXHPMC1",
|
||||
"value": [
|
||||
@@ -367,7 +336,7 @@ exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
|
||||
exports[`Phase 3: Variable System > 3.8 var list --tag important filters by label 1`] = `
|
||||
{
|
||||
"type": "AF0XACGXHPMC1",
|
||||
"value": [
|
||||
@@ -386,9 +355,9 @@ exports[`Phase 3: Variable System 3.8 var list --tag important filters by label
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
exports[`Phase 3: Variable System > 3.9 var set without label removes it 1`] = `
|
||||
{
|
||||
"type": "9103EYRMM949A",
|
||||
"type": "0Q5EMYK4SYSS9",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "@myapp/config",
|
||||
@@ -401,7 +370,7 @@ exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||
exports[`Phase 3: Variable System > 3.10 var delete removes variable 1`] = `
|
||||
{
|
||||
"type": "C3MYPR5RGQFZT",
|
||||
"value": [
|
||||
@@ -418,9 +387,9 @@ exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=@myapp/config, schema=FRBAB1BF0ZBCS"`;
|
||||
exports[`Phase 3: Variable System > 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=@myapp/config, schema=FRBAB1BF0ZBCS"`;
|
||||
|
||||
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
|
||||
exports[`Phase 4: Template System > 4.1 template set registers template 1`] = `
|
||||
{
|
||||
"type": "BJDHPAE4Q8TXM",
|
||||
"value": {
|
||||
@@ -430,14 +399,14 @@ exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
|
||||
exports[`Phase 4: Template System > 4.2 template get returns template text 1`] = `
|
||||
{
|
||||
"type": "0B0HBHZGYHR84",
|
||||
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
|
||||
exports[`Phase 4: Template System > 4.3 template list shows registered templates 1`] = `
|
||||
{
|
||||
"type": "8917JQTD1R5JF",
|
||||
"value": [
|
||||
@@ -449,7 +418,7 @@ exports[`Phase 4: Template System 4.3 template list shows registered templates 1
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
|
||||
exports[`Phase 4: Template System > 4.4 template delete removes template 1`] = `
|
||||
{
|
||||
"type": "BY7BGZJND3N7R",
|
||||
"value": {
|
||||
@@ -458,4 +427,63 @@ exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: FRBAB1BF0ZBCS"`;
|
||||
exports[`Phase 4: Template System > 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: FRBAB1BF0ZBCS"`;
|
||||
|
||||
exports[`Phase 7: Edge Cases > 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
|
||||
|
||||
exports[`Phase 7: Edge Cases > 7.3 var set empty name errors 1`] = `"Usage: ocas var set <name> <hash> [--tag <tag>...]"`;
|
||||
|
||||
exports[`Phase 7: Edge Cases > 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Name must follow @scope/name format (e.g. @myapp/config)"`;
|
||||
|
||||
exports[`Phase 7: Edge Cases > 7.5 no subcommand shows help text 1`] = `
|
||||
"Usage: ocas [--home <path>] [--json] <command> [args]
|
||||
|
||||
All JSON commands emit a { type, value } envelope. The type is the hash of the
|
||||
command's @ocas/output/* schema (shown in parentheses); pipe any envelope into
|
||||
\`render -p\` to render its value (ocas_ref hashes are expanded).
|
||||
|
||||
Commands:
|
||||
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@ocas/output/put)
|
||||
get <hash> Print node as envelope (@ocas/output/get)
|
||||
has <hash> Print envelope (value=boolean) (@ocas/output/has)
|
||||
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@ocas/output/verify)
|
||||
refs <hash> List direct ocas_ref edges (@ocas/output/refs)
|
||||
walk <hash> [--format tree] Recursive traversal (@ocas/output/walk)
|
||||
hash <type-hash> <file.json|--pipe> Compute hash without storing (@ocas/output/hash)
|
||||
render <hash> [options] Render node as text with resolution decay (raw output)
|
||||
render --pipe/-p [options] Render { type, value } from stdin (raw output)
|
||||
list --type <hash-or-name> [--tag <tag>...] List hashes for a type, optionally filtered by tags (@ocas/output/list)
|
||||
list-meta List meta-schema hashes (value=string[]) (@ocas/output/list-meta)
|
||||
list-schema List all schema hashes (value=string[]) (@ocas/output/list-schema)
|
||||
tag <target> <tag>... Apply tags/labels to a target (@ocas/output/tag)
|
||||
untag <target> <tag>... Remove tags/labels from a target (@ocas/output/untag)
|
||||
var set <name> <hash> [--tag <tag>...] Create/update a variable (@ocas/output/var-set)
|
||||
var get <name> --schema <hash> Get a variable by name + schema (@ocas/output/var-get)
|
||||
var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete)
|
||||
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list)
|
||||
var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history)
|
||||
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)
|
||||
template delete <schema-hash> Delete template for schema (@ocas/output/template-delete)
|
||||
gc Run garbage collection (@ocas/output/gc)
|
||||
export <root>... -o <file> Export CAS closure of roots to a tar bundle
|
||||
import <bundle> [--scope @s] Import nodes/vars/tags from a bundle into the store
|
||||
|
||||
Flags:
|
||||
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
|
||||
--store <bundle> Use a tar bundle as a read-only store (instead of --home)
|
||||
--json Compact JSON output
|
||||
--render, -r Render output inline (equivalent to | ocas render -p)
|
||||
--schema <hash> Schema hash filter for var get/delete/tag/list
|
||||
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
|
||||
--inline <text> Inline text content for template set
|
||||
--resolution <n> Initial resolution for render (default: 1.0)
|
||||
--decay <n> Decay factor for render (default: 0.5)
|
||||
--epsilon <n> Cutoff threshold for render (default: 0.01)
|
||||
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)
|
||||
--scope <name> Variable name remap target for import (e.g. --scope @imported)
|
||||
-o <file> Output path for export
|
||||
|
||||
Agent: If you have not installed the ocas skill for this version (0.4.0), run \`ocas prompt bootstrap\` and follow the instructions."
|
||||
`;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
|
||||
exports[`Phase 1: CAS Core > 1.6 get returns node JSON (snapshot) 1`] = `
|
||||
{
|
||||
"type": "FB4K0SXG68ZFS",
|
||||
"type": "7V5G8E2VW8B2G",
|
||||
"value": {
|
||||
"payload": {
|
||||
"age": 30,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
|
||||
exports[`Phase 5: Render > 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
|
||||
|
||||
exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
|
||||
exports[`Phase 5: Render > 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
|
||||
exports[`Phase 2: Schema Validation > 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
|
||||
exports[`Phase 1: CAS Core > 1.9 verify returns ok for valid node 1`] = `
|
||||
{
|
||||
"type": "52HEFB52BD0GF",
|
||||
"value": "ok",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
|
||||
exports[`Phase 1: CAS Core > 1.10 refs lists direct references (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "2TKP4RGBJ4V43",
|
||||
"value": []
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
|
||||
exports[`Phase 1: CAS Core > 1.11 walk shows traversal tree (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "4HG6MD3XG5H5C",
|
||||
"value": [
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
// ---- @ Alias Resolution Tests ----
|
||||
|
||||
@@ -16,7 +17,7 @@ beforeEach(() => {
|
||||
`ocas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
cliPath = join(import.meta.dir, "../src/index.ts");
|
||||
cliPath = join(import.meta.dirname, "../dist/index.js");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
@@ -34,31 +35,30 @@ afterEach(() => {
|
||||
/**
|
||||
* Run CLI command and return stdout, stderr, and exit code
|
||||
*/
|
||||
async function runCliAlias(...args: string[]): Promise<{
|
||||
function runCliAlias(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", "run", cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: proc.exitCode ?? 0,
|
||||
};
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the `value` field from a { type, value } envelope JSON string. */
|
||||
@@ -67,7 +67,7 @@ function envValue(json: string): unknown {
|
||||
}
|
||||
|
||||
describe("@ Alias Resolution - put", () => {
|
||||
test("ocas put @string <file> should resolve alias", async () => {
|
||||
test("ocas put @ocas/string <file> should resolve alias", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
@@ -85,7 +85,7 @@ describe("@ Alias Resolution - put", () => {
|
||||
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("ocas put @number <file> should resolve alias", async () => {
|
||||
test("ocas put @ocas/number <file> should resolve alias", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
@@ -101,7 +101,7 @@ describe("@ Alias Resolution - put", () => {
|
||||
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("ocas put @object <file> should resolve alias", async () => {
|
||||
test("ocas put @ocas/object <file> should resolve alias", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
@@ -133,7 +133,7 @@ describe("@ Alias Resolution - put", () => {
|
||||
expect(stderr.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("ocas put @schema with nested type constraints should succeed", async () => {
|
||||
test("ocas put @ocas/schema with nested type constraints should succeed", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const schemaFile = join(testDir, "constrained-schema.json");
|
||||
@@ -168,7 +168,7 @@ describe("@ Alias Resolution - put", () => {
|
||||
});
|
||||
|
||||
describe("@ Alias Resolution - hash", () => {
|
||||
test("ocas hash @string <file> should compute hash without storing", async () => {
|
||||
test("ocas hash @ocas/string <file> should compute hash without storing", async () => {
|
||||
await runCliAlias("init");
|
||||
|
||||
const payloadFile = join(testDir, "payload.json");
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { envValue, stripVolatile } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
|
||||
const pkgPath = resolve(import.meta.dirname, "../package.json");
|
||||
|
||||
// --- ocas command alias tests (from cli.test.ts) ---
|
||||
|
||||
describe("ocas binary", () => {
|
||||
test("T1: ocas bin entry exists in package.json", async () => {
|
||||
const pkg = await Bun.file(pkgPath).json();
|
||||
expect(pkg.bin.ocas).toBe("src/index.ts");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
expect(pkg.bin.ocas).toBe("dist/index.js");
|
||||
});
|
||||
|
||||
test("T2: no legacy bin entries (json-cas, ucas)", async () => {
|
||||
const pkg = await Bun.file(pkgPath).json();
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
expect(pkg.bin["json-cas"]).toBeUndefined();
|
||||
expect(pkg.bin["ucas"]).toBeUndefined();
|
||||
expect(pkg.bin.ucas).toBeUndefined();
|
||||
expect(Object.keys(pkg.bin)).toEqual(["ocas"]);
|
||||
});
|
||||
|
||||
test("T3: ocas command is executable and shows help", async () => {
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
test("T3: ocas command is executable and shows help", () => {
|
||||
const stdout = execFileSync("node", [entrypoint, "--help"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -38,26 +36,38 @@ describe("ocas binary", () => {
|
||||
|
||||
describe("Phase 7: Edge Cases", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
return { stdout, stderr, exitCode };
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -72,10 +82,7 @@ describe("Phase 7: Edge Cases", () => {
|
||||
const { openStore: openFsStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
typeHash = putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
@@ -130,26 +137,24 @@ describe("Phase 7: Edge Cases", () => {
|
||||
expect(combined.toLowerCase()).toContain("usage");
|
||||
});
|
||||
|
||||
test("7.6 --home path is a file errors", async () => {
|
||||
test("7.6 --home path is a file errors", () => {
|
||||
const fileAsStore = join(tmpStore, "not-a-directory");
|
||||
writeFileSync(fileAsStore, "test");
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--home",
|
||||
fileAsStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"get",
|
||||
"AAAAAAAAAAAAA",
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("not a directory");
|
||||
try {
|
||||
execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", fileAsStore, "get", "AAAAAAAAAAAAA"],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stderr?: string; status?: number };
|
||||
expect(err.status).not.toBe(0);
|
||||
expect((err.stderr ?? "").trim()).toContain("not a directory");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,26 +162,38 @@ describe("Phase 7: Edge Cases", () => {
|
||||
|
||||
describe("Phase 3: Variable System", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
return { stdout, stderr, exitCode };
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -191,10 +208,7 @@ describe("Phase 3: Variable System", () => {
|
||||
const { openStore: openFsStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
typeHash = putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
@@ -233,7 +247,11 @@ describe("Phase 3: Variable System", () => {
|
||||
test("3.3 var list shows all variables", async () => {
|
||||
const { stdout, exitCode } = await runCli(["var", "list"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
const stripped = stripVolatile(stdout) as { value: { name: string }[] };
|
||||
stripped.value.sort((a, b) =>
|
||||
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
||||
);
|
||||
expect(stripped).toMatchSnapshot();
|
||||
expect(stdout).toContain("@myapp/config");
|
||||
});
|
||||
|
||||
@@ -261,14 +279,15 @@ describe("Phase 3: Variable System", () => {
|
||||
await runCli(["var", "set", "@myapp/config", nodeHash]);
|
||||
});
|
||||
|
||||
test("3.6 var tag adds kv tag and label", async () => {
|
||||
test("3.6 var set with tag and label adds them", async () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"tag",
|
||||
"set",
|
||||
"@myapp/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
nodeHash,
|
||||
"--tag",
|
||||
"env:prod",
|
||||
"--tag",
|
||||
"important",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
@@ -299,14 +318,14 @@ describe("Phase 3: Variable System", () => {
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("3.9 var tag remove deletes label", async () => {
|
||||
test("3.9 var set without label removes it", async () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"tag",
|
||||
"set",
|
||||
"@myapp/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
":important",
|
||||
nodeHash,
|
||||
"--tag",
|
||||
"env:prod",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
@@ -347,25 +366,37 @@ describe("Phase 3: Variable System", () => {
|
||||
|
||||
describe("Phase 4: Template System", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
return { stdout, stderr, exitCode };
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -380,10 +411,7 @@ describe("Phase 4: Template System", () => {
|
||||
const { openStore: openFsStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
typeHash = putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { envValue, runCli } from "./helpers";
|
||||
|
||||
let storePath: string;
|
||||
let bundlePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
storePath = mkdtempSync(join(tmpdir(), "ocas-export-import-"));
|
||||
bundlePath = join(storePath, "bundle.tar");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(storePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function setupSampleStore(): Promise<{
|
||||
schemaHash: string;
|
||||
nodeHash: string;
|
||||
}> {
|
||||
// Create a schema, a node, a variable, and a tag.
|
||||
const { openStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openStore(storePath);
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||
required: ["name"],
|
||||
});
|
||||
const nodeHash = store.cas.put(schemaHash, { name: "Alice", age: 30 });
|
||||
store.var.set("@test/app", nodeHash);
|
||||
store.tag.tag(nodeHash, [{ op: "set", key: "env", value: "prod" }]);
|
||||
return { schemaHash, nodeHash };
|
||||
}
|
||||
|
||||
describe("CLI export/import", () => {
|
||||
test("3.1 export: basic usage with -o flag", async () => {
|
||||
const { nodeHash } = await setupSampleStore();
|
||||
const { exitCode, stdout } = runCli(
|
||||
["export", "@test/app", "-o", bundlePath],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(existsSync(bundlePath)).toBe(true);
|
||||
const value = envValue(stdout) as {
|
||||
nodes: number;
|
||||
vars: number;
|
||||
tags: number;
|
||||
};
|
||||
expect(value.nodes).toBeGreaterThan(0);
|
||||
expect(value.vars).toBeGreaterThanOrEqual(1);
|
||||
expect(value.tags).toBeGreaterThanOrEqual(1);
|
||||
void nodeHash;
|
||||
});
|
||||
|
||||
test("3.2 export: multiple roots", async () => {
|
||||
const { openStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openStore(storePath);
|
||||
const schemaHash = putSchema(store, { type: "string" });
|
||||
const aHash = store.cas.put(schemaHash, "a");
|
||||
const bHash = store.cas.put(schemaHash, "b");
|
||||
store.var.set("@test/a", aHash);
|
||||
store.var.set("@test/b", bHash);
|
||||
|
||||
const { exitCode } = runCli(
|
||||
["export", "@test/a", "@test/b", "-o", bundlePath],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(existsSync(bundlePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("3.3 export: hash as root", async () => {
|
||||
const { nodeHash } = await setupSampleStore();
|
||||
const { exitCode } = runCli(
|
||||
["export", nodeHash, "-o", bundlePath],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("3.4 export: missing root → error", async () => {
|
||||
await setupSampleStore();
|
||||
const { exitCode, stderr } = runCli(
|
||||
["export", "@test/nonexistent", "-o", bundlePath],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("3.5 export: missing -o flag → error", async () => {
|
||||
await setupSampleStore();
|
||||
const { exitCode, stderr } = runCli(["export", "@test/app"], storePath);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toMatch(/-o|output/i);
|
||||
});
|
||||
|
||||
test("3.6 import: basic", async () => {
|
||||
await setupSampleStore();
|
||||
runCli(["export", "@test/app", "-o", bundlePath], storePath);
|
||||
|
||||
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-dst-"));
|
||||
try {
|
||||
const { exitCode, stdout } = runCli(["import", bundlePath], dstPath);
|
||||
expect(exitCode).toBe(0);
|
||||
const stats = envValue(stdout) as {
|
||||
nodes: { imported: number; skipped: number };
|
||||
vars: { created: number; updated: number };
|
||||
tags: number;
|
||||
};
|
||||
expect(stats.nodes.imported).toBeGreaterThan(0);
|
||||
|
||||
// Variable accessible in dst.
|
||||
const get = runCli(["get", "@test/app"], dstPath);
|
||||
expect(get.exitCode).toBe(0);
|
||||
} finally {
|
||||
rmSync(dstPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("3.7 import --scope remaps variables", async () => {
|
||||
await setupSampleStore();
|
||||
runCli(["export", "@test/app", "-o", bundlePath], storePath);
|
||||
|
||||
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-scope-"));
|
||||
try {
|
||||
const { exitCode } = runCli(
|
||||
["import", bundlePath, "--scope", "@imported"],
|
||||
dstPath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const list = runCli(["var", "list", "@imported"], dstPath);
|
||||
expect(list.exitCode).toBe(0);
|
||||
const variables = envValue(list.stdout) as Array<{ name: string }>;
|
||||
expect(variables.some((v) => v.name === "@imported/app")).toBe(true);
|
||||
} finally {
|
||||
rmSync(dstPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("3.8 import is idempotent", async () => {
|
||||
await setupSampleStore();
|
||||
runCli(["export", "@test/app", "-o", bundlePath], storePath);
|
||||
|
||||
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-idem-"));
|
||||
try {
|
||||
runCli(["import", bundlePath], dstPath);
|
||||
const second = runCli(["import", bundlePath], dstPath);
|
||||
expect(second.exitCode).toBe(0);
|
||||
const stats = envValue(second.stdout) as {
|
||||
nodes: { imported: number; skipped: number };
|
||||
};
|
||||
expect(stats.nodes.imported).toBe(0);
|
||||
expect(stats.nodes.skipped).toBeGreaterThan(0);
|
||||
} finally {
|
||||
rmSync(dstPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("3.9 --store flag: ocas get reads from bundle", async () => {
|
||||
const { nodeHash } = await setupSampleStore();
|
||||
runCli(["export", "@test/app", "-o", bundlePath], storePath);
|
||||
|
||||
const { exitCode, stdout } = runCli([
|
||||
"get",
|
||||
nodeHash,
|
||||
"--store",
|
||||
bundlePath,
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
const value = envValue(stdout) as { payload: { name: string } };
|
||||
expect(value.payload.name).toBe("Alice");
|
||||
});
|
||||
|
||||
test("3.10 --store flag: ocas var list reads from bundle", async () => {
|
||||
await setupSampleStore();
|
||||
runCli(["export", "@test/app", "-o", bundlePath], storePath);
|
||||
|
||||
const { exitCode, stdout } = runCli([
|
||||
"var",
|
||||
"list",
|
||||
"@test",
|
||||
"--store",
|
||||
bundlePath,
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
const variables = envValue(stdout) as Array<{ name: string }>;
|
||||
expect(variables.some((v) => v.name === "@test/app")).toBe(true);
|
||||
});
|
||||
|
||||
test("3.11 --store flag: ocas walk reads from bundle", async () => {
|
||||
const { nodeHash } = await setupSampleStore();
|
||||
runCli(["export", "@test/app", "-o", bundlePath], storePath);
|
||||
|
||||
const { exitCode, stdout } = runCli([
|
||||
"walk",
|
||||
nodeHash,
|
||||
"--store",
|
||||
bundlePath,
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
const hashes = envValue(stdout) as string[];
|
||||
expect(hashes).toContain(nodeHash);
|
||||
});
|
||||
|
||||
test("3.12 --store flag: ocas refs reads from bundle", async () => {
|
||||
const { nodeHash } = await setupSampleStore();
|
||||
runCli(["export", "@test/app", "-o", bundlePath], storePath);
|
||||
|
||||
const { exitCode } = runCli(["refs", nodeHash, "--store", bundlePath]);
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("3.13 --store flag: ocas has reads from bundle", async () => {
|
||||
const { nodeHash } = await setupSampleStore();
|
||||
runCli(["export", "@test/app", "-o", bundlePath], storePath);
|
||||
|
||||
const present = runCli(["has", nodeHash, "--store", bundlePath]);
|
||||
expect(present.exitCode).toBe(0);
|
||||
expect(envValue(present.stdout)).toBe(true);
|
||||
|
||||
const missing = runCli(["has", "AAAAAAAAAAAAA", "--store", bundlePath]);
|
||||
expect(missing.exitCode).toBe(0);
|
||||
expect(envValue(missing.stdout)).toBe(false);
|
||||
});
|
||||
|
||||
test("3.14 --store flag: write commands fail with 'read-only' error", async () => {
|
||||
await setupSampleStore();
|
||||
runCli(["export", "@test/app", "-o", bundlePath], storePath);
|
||||
|
||||
// Try `ocas put` against a bundle.
|
||||
const tmp = mkdtempSync(join(tmpdir(), "ocas-store-write-"));
|
||||
try {
|
||||
const payload = join(tmp, "p.json");
|
||||
writeFileSync(payload, JSON.stringify({ name: "X" }));
|
||||
const { exitCode, stderr } = runCli([
|
||||
"put",
|
||||
"@ocas/string",
|
||||
payload,
|
||||
"--store",
|
||||
bundlePath,
|
||||
]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toMatch(/read[- ]only/i);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Suppress unused.
|
||||
void mkdirSync;
|
||||
+136
-39
@@ -1,19 +1,18 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { envValue } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -28,10 +27,7 @@ beforeAll(async () => {
|
||||
const { openStore: openFsStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
typeHash = putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
@@ -46,17 +42,30 @@ afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
return { stdout, stderr, exitCode };
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Phase 6: GC ----
|
||||
@@ -76,28 +85,19 @@ describe("Phase 6: GC", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("6.2 gc | render -p renders the gc stats", async () => {
|
||||
const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]);
|
||||
test("6.2 gc | render -p renders the gc stats", () => {
|
||||
const { stdout: gcOut, exitCode: gcExit } = runCli(["gc"]);
|
||||
expect(gcExit).toBe(0);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--home",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"render",
|
||||
"--pipe",
|
||||
],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
proc.stdin.write(gcOut);
|
||||
proc.stdin.end();
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
expect(exitCode).toBe(0);
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, "render", "--pipe"],
|
||||
{
|
||||
input: gcOut,
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
).trim();
|
||||
// gc value is an object { total, reachable, collected, scanned }
|
||||
expect(stdout).toContain("total:");
|
||||
});
|
||||
@@ -123,3 +123,100 @@ describe("Phase 6: GC", () => {
|
||||
expect(envValue(afterGc)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #93: gc must not collect uwf-style step chains joined by oneOf prev
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("GC #93 - oneOf step chain CLI integration", () => {
|
||||
test("6.5 gc preserves step chain joined by oneOf prev", async () => {
|
||||
const subStore = mkdtempSync(join(tmpdir(), "ocas-e2e-93-"));
|
||||
try {
|
||||
const { openStore: openFsStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openFsStore(subStore);
|
||||
|
||||
const stepSchemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
payload: { type: "string" },
|
||||
prev: {
|
||||
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const step1File = join(subStore, "step1.json");
|
||||
writeFileSync(step1File, JSON.stringify({ payload: "a", prev: null }));
|
||||
const runCliSub = (
|
||||
args: string[],
|
||||
): { stdout: string; stderr: string; exitCode: number } => {
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", subStore, ...args],
|
||||
{ encoding: "utf-8", timeout: 10000 },
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
status?: number;
|
||||
};
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { stdout: s1Out } = runCliSub(["put", stepSchemaHash, step1File]);
|
||||
const step1Hash = envValue(s1Out) as string;
|
||||
|
||||
const step2File = join(subStore, "step2.json");
|
||||
writeFileSync(
|
||||
step2File,
|
||||
JSON.stringify({ payload: "b", prev: step1Hash }),
|
||||
);
|
||||
const { stdout: s2Out } = runCliSub(["put", stepSchemaHash, step2File]);
|
||||
const step2Hash = envValue(s2Out) as string;
|
||||
|
||||
const step3File = join(subStore, "step3.json");
|
||||
writeFileSync(
|
||||
step3File,
|
||||
JSON.stringify({ payload: "c", prev: step2Hash }),
|
||||
);
|
||||
const { stdout: s3Out } = runCliSub(["put", stepSchemaHash, step3File]);
|
||||
const step3Hash = envValue(s3Out) as string;
|
||||
|
||||
const orphanFile = join(subStore, "orphan-step.json");
|
||||
writeFileSync(
|
||||
orphanFile,
|
||||
JSON.stringify({ payload: "orphan", prev: null }),
|
||||
);
|
||||
const { stdout: orphanOut } = runCliSub([
|
||||
"put",
|
||||
stepSchemaHash,
|
||||
orphanFile,
|
||||
]);
|
||||
const orphanHash = envValue(orphanOut) as string;
|
||||
|
||||
runCliSub(["var", "set", "@test/thread/head", step3Hash]);
|
||||
|
||||
const { exitCode } = runCliSub(["gc"]);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const { stdout: has1 } = runCliSub(["has", step1Hash]);
|
||||
expect(envValue(has1)).toBe(true);
|
||||
const { stdout: has2 } = runCliSub(["has", step2Hash]);
|
||||
expect(envValue(has2)).toBe(true);
|
||||
const { stdout: has3 } = runCliSub(["has", step3Hash]);
|
||||
expect(envValue(has3)).toBe(true);
|
||||
const { stdout: hasOrphan } = runCliSub(["has", orphanHash]);
|
||||
expect(envValue(hasOrphan)).toBe(false);
|
||||
} finally {
|
||||
rmSync(subStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash } from "@ocas/core";
|
||||
import { bootstrap, validate } from "@ocas/core";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(
|
||||
tmpdir(),
|
||||
`ocas-get-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
cliPath = join(import.meta.dirname, "../dist/index.js");
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function createTestNode(value = "hello-world"): Promise<Hash> {
|
||||
const store = await openFsStore(storePath);
|
||||
const aliases = bootstrap(store);
|
||||
const typeHash = aliases["@ocas/string"];
|
||||
if (!typeHash) throw new Error("@ocas/string not found");
|
||||
return store.cas.put(typeHash, value);
|
||||
}
|
||||
|
||||
describe("get/var-get with tags", () => {
|
||||
test("G1: ocas get on untagged node — output unchanged (no tags field)", async () => {
|
||||
const hash = await createTestNode("hello");
|
||||
const { stdout, exitCode } = await runCli("get", hash);
|
||||
expect(exitCode).toBe(0);
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(typeof envelope.value.type).toBe("string");
|
||||
expect(envelope.value.payload).toBe("hello");
|
||||
expect(typeof envelope.value.timestamp).toBe("number");
|
||||
expect("tags" in envelope.value).toBe(false);
|
||||
});
|
||||
|
||||
test("G2: ocas get on tagged node — includes tags array", async () => {
|
||||
const hash = await createTestNode("tagged");
|
||||
await runCli("tag", hash, "env:prod", "stable", "owner:alice");
|
||||
|
||||
const { stdout, exitCode } = await runCli("get", hash);
|
||||
expect(exitCode).toBe(0);
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.payload).toBe("tagged");
|
||||
expect(typeof envelope.value.timestamp).toBe("number");
|
||||
expect(Array.isArray(envelope.value.tags)).toBe(true);
|
||||
expect(envelope.value.tags).toHaveLength(3);
|
||||
|
||||
const pairs = new Set(
|
||||
envelope.value.tags.map(
|
||||
(t: { key: string; value: string | null }) => `${t.key}=${t.value}`,
|
||||
),
|
||||
);
|
||||
expect(pairs.has("env=prod")).toBe(true);
|
||||
expect(pairs.has("stable=null")).toBe(true);
|
||||
expect(pairs.has("owner=alice")).toBe(true);
|
||||
|
||||
for (const t of envelope.value.tags) {
|
||||
expect(t.target).toBe(hash);
|
||||
expect(typeof t.created).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("G3: ocas get after untag removes all tags — no tags key (empty array not serialized)", async () => {
|
||||
const hash = await createTestNode("u3");
|
||||
await runCli("tag", hash, "k:v");
|
||||
await runCli("untag", hash, "k");
|
||||
|
||||
const { stdout, exitCode } = await runCli("get", hash);
|
||||
expect(exitCode).toBe(0);
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect("tags" in envelope.value).toBe(false);
|
||||
});
|
||||
|
||||
test("V1: ocas var get when value hash untagged — output unchanged (no valueTags)", async () => {
|
||||
const hash = await createTestNode("hello");
|
||||
await runCli("var", "set", "@user/foo", hash);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@user/foo",
|
||||
"--schema",
|
||||
"@ocas/string",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.name).toBe("@user/foo");
|
||||
expect(envelope.value.value).toBe(hash);
|
||||
expect(envelope.value.tags).toEqual({});
|
||||
expect(envelope.value.labels).toEqual([]);
|
||||
expect("valueTags" in envelope.value).toBe(false);
|
||||
});
|
||||
|
||||
test("V2: ocas var get when value hash tagged — includes valueTags array", async () => {
|
||||
const hash = await createTestNode("hello");
|
||||
await runCli("var", "set", "@user/foo", hash);
|
||||
await runCli("tag", hash, "env:prod", "stable");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@user/foo",
|
||||
"--schema",
|
||||
"@ocas/string",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.tags).toEqual({});
|
||||
expect(envelope.value.labels).toEqual([]);
|
||||
expect(Array.isArray(envelope.value.valueTags)).toBe(true);
|
||||
expect(envelope.value.valueTags).toHaveLength(2);
|
||||
|
||||
const pairs = new Set(
|
||||
envelope.value.valueTags.map(
|
||||
(t: { key: string; value: string | null }) => `${t.key}=${t.value}`,
|
||||
),
|
||||
);
|
||||
expect(pairs.has("env=prod")).toBe(true);
|
||||
expect(pairs.has("stable=null")).toBe(true);
|
||||
for (const t of envelope.value.valueTags) {
|
||||
expect(t.target).toBe(hash);
|
||||
expect(typeof t.created).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("V3: variable tags vs valueTags don't collide", async () => {
|
||||
const hash = await createTestNode("hello");
|
||||
await runCli("var", "set", "@user/foo", hash, "--tag", "a:1", "--tag", "x");
|
||||
await runCli("tag", hash, "owner:bob");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@user/foo",
|
||||
"--schema",
|
||||
"@ocas/string",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.tags).toEqual({ a: "1" });
|
||||
expect(envelope.value.labels).toEqual(["x"]);
|
||||
expect(Array.isArray(envelope.value.valueTags)).toBe(true);
|
||||
expect(envelope.value.valueTags).toHaveLength(1);
|
||||
expect(envelope.value.valueTags[0]).toMatchObject({
|
||||
key: "owner",
|
||||
value: "bob",
|
||||
target: hash,
|
||||
});
|
||||
});
|
||||
|
||||
test("S1: @ocas/output/get schema validates with and without tags", async () => {
|
||||
const store = await openFsStore(storePath);
|
||||
const aliases = bootstrap(store);
|
||||
const getSchemaHash = aliases["@ocas/output/get"];
|
||||
if (!getSchemaHash) throw new Error("schema not found");
|
||||
const stringHash = aliases["@ocas/string"];
|
||||
if (!stringHash) throw new Error("string schema not found");
|
||||
|
||||
const untagged = {
|
||||
type: getSchemaHash,
|
||||
payload: { type: stringHash, payload: "hi", timestamp: 1 },
|
||||
timestamp: 2,
|
||||
};
|
||||
expect(validate(store, untagged)).toBe(true);
|
||||
|
||||
const tagged = {
|
||||
type: getSchemaHash,
|
||||
payload: {
|
||||
type: stringHash,
|
||||
payload: "hi",
|
||||
timestamp: 1,
|
||||
tags: [
|
||||
{
|
||||
key: "env",
|
||||
value: "prod",
|
||||
target: stringHash,
|
||||
created: 123,
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: 2,
|
||||
};
|
||||
expect(validate(store, tagged)).toBe(true);
|
||||
});
|
||||
|
||||
test("S2: @ocas/output/var-get schema validates with and without valueTags", async () => {
|
||||
const store = await openFsStore(storePath);
|
||||
const aliases = bootstrap(store);
|
||||
const varGetSchemaHash = aliases["@ocas/output/var-get"];
|
||||
if (!varGetSchemaHash) throw new Error("schema not found");
|
||||
const stringHash = aliases["@ocas/string"];
|
||||
if (!stringHash) throw new Error("string schema not found");
|
||||
|
||||
const untagged = {
|
||||
type: varGetSchemaHash,
|
||||
payload: {
|
||||
name: "@user/foo",
|
||||
schema: stringHash,
|
||||
value: stringHash,
|
||||
created: 1,
|
||||
updated: 2,
|
||||
tags: {},
|
||||
labels: [],
|
||||
},
|
||||
timestamp: 3,
|
||||
};
|
||||
expect(validate(store, untagged)).toBe(true);
|
||||
|
||||
const tagged = {
|
||||
type: varGetSchemaHash,
|
||||
payload: {
|
||||
name: "@user/foo",
|
||||
schema: stringHash,
|
||||
value: stringHash,
|
||||
created: 1,
|
||||
updated: 2,
|
||||
tags: {},
|
||||
labels: [],
|
||||
valueTags: [
|
||||
{
|
||||
key: "env",
|
||||
value: "prod",
|
||||
target: stringHash,
|
||||
created: 123,
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: 3,
|
||||
};
|
||||
expect(validate(store, tagged)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -22,8 +23,8 @@ export {
|
||||
writeFileSync,
|
||||
};
|
||||
|
||||
export const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
export const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||
export const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
|
||||
export const pkgPath = resolve(import.meta.dirname, "../package.json");
|
||||
|
||||
/** Extract the `value` field from a { type, value } envelope JSON string. */
|
||||
export function envValue(json: string): unknown {
|
||||
@@ -42,48 +43,62 @@ export async function putSchemaFile(
|
||||
const schema = JSON.parse(
|
||||
readFileSync(schemaFilePath, "utf-8"),
|
||||
) as JSONSchema;
|
||||
const hash = await putSchema(store, schema);
|
||||
const hash = putSchema(store, schema);
|
||||
return hash;
|
||||
}
|
||||
|
||||
const quietEnv = { ...process.env, NODE_NO_WARNINGS: "1" };
|
||||
|
||||
/**
|
||||
* Run CLI command. Accepts either a string[] or ...string[] (rest args).
|
||||
* If first arg is an array, uses that as args. Otherwise treats all args as the command.
|
||||
*/
|
||||
export async function runCli(
|
||||
export function runCli(
|
||||
args: string[],
|
||||
storePath?: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
): { stdout: string; stderr: string; exitCode: number } {
|
||||
const finalArgs = storePath
|
||||
? ["bun", entrypoint, "--home", storePath, ...args]
|
||||
: ["bun", entrypoint, ...args];
|
||||
const proc = Bun.spawn(finalArgs, {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return { stdout, stderr, exitCode };
|
||||
? [entrypoint, "--home", storePath, ...args]
|
||||
: [entrypoint, ...args];
|
||||
try {
|
||||
const stdout = execFileSync("node", finalArgs, {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
env: quietEnv,
|
||||
});
|
||||
return { stdout, stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: err.stdout ?? "",
|
||||
stderr: err.stderr ?? "",
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCliWithStdin(
|
||||
export function runCliWithStdin(
|
||||
args: string[],
|
||||
storePath: string,
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const finalArgs = ["bun", entrypoint, "--home", storePath, ...args];
|
||||
const proc = Bun.spawn(finalArgs, {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
});
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return { stdout, stderr, exitCode };
|
||||
): { stdout: string; stderr: string; exitCode: number } {
|
||||
const finalArgs = [entrypoint, "--home", storePath, ...args];
|
||||
try {
|
||||
const stdout = execFileSync("node", finalArgs, {
|
||||
input: stdin,
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
env: quietEnv,
|
||||
});
|
||||
return { stdout, stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: err.stdout ?? "",
|
||||
stderr: err.stderr ?? "",
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { BOOTSTRAP_STORE } from "@ocas/core";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { envValue, runCli } from "./helpers.js";
|
||||
|
||||
let storePath: string;
|
||||
@@ -19,7 +19,7 @@ afterEach(() => {
|
||||
|
||||
describe("list-meta CLI command", () => {
|
||||
test("E1. list-meta on bootstrapped store contains exactly the meta-schema hash", async () => {
|
||||
// First, get @schema hash by calling has on it (also triggers bootstrap)
|
||||
// First, get @ocas/schema hash by calling has on it (also triggers bootstrap)
|
||||
const { stdout: hashOut, exitCode: hashCode } = await runCli(
|
||||
["hash", "@ocas/schema", "--pipe"],
|
||||
storePath,
|
||||
@@ -28,7 +28,7 @@ describe("list-meta CLI command", () => {
|
||||
void hashOut;
|
||||
void hashCode;
|
||||
|
||||
// Bootstrap fully via 'list --type @schema'
|
||||
// Bootstrap fully via 'list --type @ocas/schema'
|
||||
const { stdout: schemaListOut } = await runCli(
|
||||
["list", "--type", "@ocas/schema"],
|
||||
storePath,
|
||||
@@ -119,19 +119,19 @@ describe("F1. output schemas registered", () => {
|
||||
});
|
||||
|
||||
describe("E4. list-schema vs list --type with multiple meta-schema versions", () => {
|
||||
test("list-schema includes schemas typed by older meta-schemas; list --type @schema does not", async () => {
|
||||
test("list-schema includes schemas typed by older meta-schemas; list --type @ocas/schema does not", async () => {
|
||||
// Set up two distinct meta-schemas via the library
|
||||
const store = await openFsStore(storePath);
|
||||
const m1 = await store[BOOTSTRAP_STORE]({
|
||||
const m1 = await store.cas[BOOTSTRAP_STORE]({
|
||||
type: "object",
|
||||
title: "meta-v1",
|
||||
});
|
||||
const m2 = await store[BOOTSTRAP_STORE]({
|
||||
const m2 = await store.cas[BOOTSTRAP_STORE]({
|
||||
type: "object",
|
||||
title: "meta-v2",
|
||||
});
|
||||
// schema typed by older meta M1
|
||||
const sM1 = await store.put(m1, { type: "string" });
|
||||
const sM1 = store.cas.put(m1, { type: "string" });
|
||||
expect(m1).not.toBe(m2);
|
||||
|
||||
// CLI: list-schema must include sM1
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { envValue, runCli } from "./helpers.js";
|
||||
|
||||
const HASH_RE = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||
@@ -18,24 +19,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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" },
|
||||
const entrypoint = join(import.meta.dirname, "../dist/index.js");
|
||||
const out = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", storePath, "put", "@ocas/string", "--pipe"],
|
||||
{
|
||||
input: JSON.stringify(text),
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(
|
||||
tmpdir(),
|
||||
`ocas-list-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
cliPath = join(import.meta.dirname, "../dist/index.js");
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function putString(value: string): string {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"node",
|
||||
[cliPath, "--home", storePath, "put", "@ocas/string", "--pipe"],
|
||||
{
|
||||
input: JSON.stringify(value),
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return JSON.parse(out.trim()).value as string;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stderr?: string };
|
||||
throw new Error(`put failed: ${err.stderr ?? ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function tag(target: string, ...tagSpecs: string[]): Promise<void> {
|
||||
const { exitCode, stderr } = await runCli("tag", target, ...tagSpecs);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`tag failed: ${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function varSet(
|
||||
name: string,
|
||||
hash: string,
|
||||
...tagSpecs: string[]
|
||||
): Promise<void> {
|
||||
const args = ["var", "set", name, hash];
|
||||
for (const t of tagSpecs) {
|
||||
args.push("--tag", t);
|
||||
}
|
||||
const { exitCode, stderr } = await runCli(...args);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`var set failed: ${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseListHashes(stdout: string): string[] {
|
||||
const env = JSON.parse(stdout);
|
||||
const value = env.value as Array<{ hash: string }>;
|
||||
return value.map((e) => e.hash);
|
||||
}
|
||||
|
||||
function parseVarNames(stdout: string): string[] {
|
||||
const env = JSON.parse(stdout);
|
||||
const value = env.value as Array<{ name: string }>;
|
||||
return value.map((v) => v.name);
|
||||
}
|
||||
|
||||
describe("ocas list --tag", () => {
|
||||
test("A1. filter by single key-value tag", async () => {
|
||||
const n1 = await putString("a1-1");
|
||||
const n2 = await putString("a1-2");
|
||||
const n3 = await putString("a1-3");
|
||||
await tag(n1, "env:prod");
|
||||
await tag(n2, "env:prod");
|
||||
await tag(n3, "env:staging");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"list",
|
||||
"--type",
|
||||
"@ocas/string",
|
||||
"--tag",
|
||||
"env:prod",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const hashes = parseListHashes(stdout);
|
||||
expect(hashes.sort()).toEqual([n1, n2].sort());
|
||||
});
|
||||
|
||||
test("A2. filter by single bare label", async () => {
|
||||
const n1 = await putString("a2-1");
|
||||
const n2 = await putString("a2-2");
|
||||
await tag(n1, "featured");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"list",
|
||||
"--type",
|
||||
"@ocas/string",
|
||||
"--tag",
|
||||
"featured",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const hashes = parseListHashes(stdout);
|
||||
expect(hashes).toEqual([n1]);
|
||||
expect(hashes).not.toContain(n2);
|
||||
});
|
||||
|
||||
test("A3. multiple --tag flags = AND", async () => {
|
||||
const n1 = await putString("a3-1");
|
||||
const n2 = await putString("a3-2");
|
||||
const n3 = await putString("a3-3");
|
||||
await tag(n1, "env:prod", "tier:gold");
|
||||
await tag(n2, "env:prod", "tier:silver");
|
||||
await tag(n3, "env:staging", "tier:gold");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"list",
|
||||
"--type",
|
||||
"@ocas/string",
|
||||
"--tag",
|
||||
"env:prod",
|
||||
"--tag",
|
||||
"tier:gold",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const hashes = parseListHashes(stdout);
|
||||
expect(hashes).toEqual([n1]);
|
||||
});
|
||||
|
||||
test("A4. mix tag and label (AND)", async () => {
|
||||
const n1 = await putString("a4-1");
|
||||
const n2 = await putString("a4-2");
|
||||
await tag(n1, "env:prod", "featured");
|
||||
await tag(n2, "env:prod");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"list",
|
||||
"--type",
|
||||
"@ocas/string",
|
||||
"--tag",
|
||||
"env:prod",
|
||||
"--tag",
|
||||
"featured",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const hashes = parseListHashes(stdout);
|
||||
expect(hashes).toEqual([n1]);
|
||||
});
|
||||
|
||||
test("A5. no matching nodes returns empty list", async () => {
|
||||
const n1 = await putString("a5-1");
|
||||
await tag(n1, "env:prod");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"list",
|
||||
"--type",
|
||||
"@ocas/string",
|
||||
"--tag",
|
||||
"env:nope",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const hashes = parseListHashes(stdout);
|
||||
expect(hashes).toEqual([]);
|
||||
});
|
||||
|
||||
test("A6. no --tag flag → existing behavior unchanged", async () => {
|
||||
const n1 = await putString("a6-1");
|
||||
const n2 = await putString("a6-2");
|
||||
|
||||
const { stdout, exitCode } = await runCli("list", "--type", "@ocas/string");
|
||||
expect(exitCode).toBe(0);
|
||||
const hashes = parseListHashes(stdout);
|
||||
expect(hashes).toContain(n1);
|
||||
expect(hashes).toContain(n2);
|
||||
});
|
||||
|
||||
test("A7. --tag with --limit", async () => {
|
||||
const n1 = await putString("a7-1");
|
||||
const n2 = await putString("a7-2");
|
||||
const n3 = await putString("a7-3");
|
||||
await tag(n1, "env:prod");
|
||||
await tag(n2, "env:prod");
|
||||
await tag(n3, "env:prod");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"list",
|
||||
"--type",
|
||||
"@ocas/string",
|
||||
"--tag",
|
||||
"env:prod",
|
||||
"--limit",
|
||||
"2",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const hashes = parseListHashes(stdout);
|
||||
expect(hashes).toHaveLength(2);
|
||||
for (const h of hashes) {
|
||||
expect([n1, n2, n3]).toContain(h);
|
||||
}
|
||||
});
|
||||
|
||||
test("A8. filter restricts to requested type", async () => {
|
||||
const n1 = await putString("a8-1");
|
||||
await tag(n1, "env:prod");
|
||||
// Tag a non-string node (a schema) with the same tag
|
||||
// Use @ocas/schema as a known schema-typed node
|
||||
const { stdout: schemaListStdout } = await runCli(
|
||||
"list-schema",
|
||||
"--limit",
|
||||
"1000",
|
||||
);
|
||||
const schemaEntries = JSON.parse(schemaListStdout).value as Array<{
|
||||
hash: string;
|
||||
}>;
|
||||
expect(schemaEntries.length).toBeGreaterThan(0);
|
||||
const otherTypeHash = schemaEntries[0]!.hash;
|
||||
await tag(otherTypeHash, "env:prod");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"list",
|
||||
"--type",
|
||||
"@ocas/string",
|
||||
"--tag",
|
||||
"env:prod",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const hashes = parseListHashes(stdout);
|
||||
expect(hashes).toContain(n1);
|
||||
expect(hashes).not.toContain(otherTypeHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ocas var list --tag", () => {
|
||||
test("B1. filter by key-value tag", async () => {
|
||||
const h1 = await putString("b1-1");
|
||||
const h2 = await putString("b1-2");
|
||||
await varSet("@app/db1", h1, "env:prod");
|
||||
await varSet("@app/db2", h2, "env:staging");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"list",
|
||||
"--tag",
|
||||
"env:prod",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const names = parseVarNames(stdout);
|
||||
expect(names).toContain("@app/db1");
|
||||
expect(names).not.toContain("@app/db2");
|
||||
});
|
||||
|
||||
test("B2. filter by bare label", async () => {
|
||||
const h1 = await putString("b2-1");
|
||||
const h2 = await putString("b2-2");
|
||||
await varSet("@app/v1", h1, "stable");
|
||||
await varSet("@app/v2", h2);
|
||||
|
||||
const { stdout, exitCode } = await runCli("var", "list", "--tag", "stable");
|
||||
expect(exitCode).toBe(0);
|
||||
const names = parseVarNames(stdout);
|
||||
expect(names).toContain("@app/v1");
|
||||
expect(names).not.toContain("@app/v2");
|
||||
});
|
||||
|
||||
test("B3. multiple --tag flags = AND", async () => {
|
||||
const h1 = await putString("b3-1");
|
||||
const h2 = await putString("b3-2");
|
||||
const h3 = await putString("b3-3");
|
||||
await varSet("@app/v1", h1, "env:prod", "stable");
|
||||
await varSet("@app/v2", h2, "env:prod");
|
||||
await varSet("@app/v3", h3, "stable");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"list",
|
||||
"--tag",
|
||||
"env:prod",
|
||||
"--tag",
|
||||
"stable",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const names = parseVarNames(stdout);
|
||||
expect(names.filter((n) => n.startsWith("@app/"))).toEqual(["@app/v1"]);
|
||||
});
|
||||
|
||||
test("B4. no --tag → existing behavior unchanged", async () => {
|
||||
const h1 = await putString("b4-1");
|
||||
await varSet("@app/v1", h1);
|
||||
|
||||
const { stdout, exitCode } = await runCli("var", "list", "@app/");
|
||||
expect(exitCode).toBe(0);
|
||||
const names = parseVarNames(stdout);
|
||||
expect(names).toContain("@app/v1");
|
||||
});
|
||||
|
||||
test("B5. --tag combined with namePrefix", async () => {
|
||||
const h1 = await putString("b5-1");
|
||||
const h2 = await putString("b5-2");
|
||||
await varSet("@app/db1", h1, "env:prod");
|
||||
await varSet("@other/db1", h2, "env:prod");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"list",
|
||||
"@app/",
|
||||
"--tag",
|
||||
"env:prod",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const names = parseVarNames(stdout);
|
||||
expect(names).toContain("@app/db1");
|
||||
expect(names).not.toContain("@other/db1");
|
||||
});
|
||||
|
||||
test("B6. empty result when no matching variables", async () => {
|
||||
const h1 = await putString("b6-1");
|
||||
await varSet("@app/v1", h1);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"list",
|
||||
"--tag",
|
||||
"missing",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const names = parseVarNames(stdout);
|
||||
expect(names.filter((n) => n.startsWith("@app/"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,18 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
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 { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { envValue } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
let _nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -28,15 +27,12 @@ beforeAll(async () => {
|
||||
const { openStore: openFsStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
typeHash = putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
|
||||
|
||||
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");
|
||||
@@ -48,33 +44,55 @@ afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
return { stdout, stderr, exitCode };
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runCliWithStdin(
|
||||
function runCliWithStdin(
|
||||
args: string[],
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
return { stdout, stderr, exitCode };
|
||||
): { stdout: string; stderr: string; exitCode: number } {
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, ...args],
|
||||
{
|
||||
input: stdin,
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Phase 8: Pipe Composition ----
|
||||
@@ -101,7 +119,7 @@ describe("Phase 8: Pipe Composition", () => {
|
||||
expect(stdout).toContain("Bob");
|
||||
});
|
||||
|
||||
test("8.3 list --type @schema emits a parseable envelope of hashes", async () => {
|
||||
test("8.3 list --type @ocas/schema emits a parseable envelope of hashes", async () => {
|
||||
const { stdout, exitCode } = await runCli([
|
||||
"list",
|
||||
"--type",
|
||||
@@ -117,7 +135,7 @@ describe("Phase 8: Pipe Composition", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("8.4 list --type @schema | render -p expands the schema list", async () => {
|
||||
test("8.4 list --type @ocas/schema | render -p expands the schema list", async () => {
|
||||
const { stdout: listOut } = await runCli([
|
||||
"list",
|
||||
"--type",
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { envValue, stripVolatile } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -31,10 +30,7 @@ beforeAll(async () => {
|
||||
const { openStore: openFsStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
typeHash = putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
@@ -46,21 +42,33 @@ afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
return { stdout, stderr, exitCode };
|
||||
function runCli(args: string[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("Phase 1: CAS Core", () => {
|
||||
test("1.1 init + put with @object bootstraps store", async () => {
|
||||
test("1.1 init + put with @ocas/object bootstraps store", async () => {
|
||||
expect(typeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { envValue, putSchemaFile, runCli, runCliWithStdin } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
|
||||
|
||||
// --- Standalone render tests from cli.test.ts ---
|
||||
|
||||
@@ -54,42 +55,62 @@ describe("ocas render command", () => {
|
||||
|
||||
describe("Phase 5: Render", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
async function runCliE2e(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
return { stdout, stderr, exitCode };
|
||||
function runCliE2e(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runCliE2eWithStdin(
|
||||
async function _runCliE2eWithStdin(
|
||||
args: string[],
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
return { stdout, stderr, exitCode };
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, ...args],
|
||||
{
|
||||
input: stdin,
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -104,10 +125,7 @@ describe("Phase 5: Render", () => {
|
||||
const { openStore: openFsStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
typeHash = putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
@@ -198,7 +216,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 +339,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 });
|
||||
}
|
||||
@@ -421,9 +439,9 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Get @string type hash via bootstrap
|
||||
const store = createFsStore(tmpStore);
|
||||
const types = await bootstrap(store);
|
||||
// Get @ocas/string type hash via bootstrap
|
||||
const store = await openFsStore(tmpStore);
|
||||
const types = bootstrap(store);
|
||||
const stringType = types["@ocas/string"];
|
||||
|
||||
// Create and store a simple string node
|
||||
@@ -456,9 +474,9 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Get @string type hash via bootstrap
|
||||
const store = createFsStore(tmpStore);
|
||||
const types = await bootstrap(store);
|
||||
// Get @ocas/string type hash via bootstrap
|
||||
const store = await openFsStore(tmpStore);
|
||||
const types = bootstrap(store);
|
||||
const stringType = types["@ocas/string"];
|
||||
|
||||
// Create envelope and pipe to render
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { envValue, putSchemaFile, runCli } from "./helpers";
|
||||
|
||||
// ---- Issue #50: Schema Validation in put Command ----
|
||||
@@ -556,15 +557,13 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
// e2e Phase 2 tests
|
||||
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");
|
||||
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -579,30 +578,19 @@ describe("Phase 2: Schema Validation", () => {
|
||||
const { openStore: openFsStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
typeHash = putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--home",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"put",
|
||||
typeHash,
|
||||
nodeFile,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
nodeHash = envValue(stdout) as string;
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, "put", typeHash, nodeFile],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
).trim();
|
||||
_nodeHash = envValue(stdout) as string;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -612,23 +600,25 @@ describe("Phase 2: Schema Validation", () => {
|
||||
test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => {
|
||||
const badFile = join(tmpStore, "bad-node.json");
|
||||
writeFileSync(badFile, JSON.stringify({ name: 123 }));
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--home",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"put",
|
||||
typeHash,
|
||||
badFile,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
let stdout = "",
|
||||
stderr = "",
|
||||
exitCode = 0;
|
||||
try {
|
||||
stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, "put", typeHash, badFile],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
||||
},
|
||||
).trim();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
stdout = (err.stdout ?? "").trim();
|
||||
stderr = (err.stderr ?? "").trim();
|
||||
exitCode = err.status ?? 1;
|
||||
}
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr).toContain("Validation failed");
|
||||
@@ -637,22 +627,23 @@ describe("Phase 2: Schema Validation", () => {
|
||||
|
||||
test("2.3 put against non-existent schema hash fails", async () => {
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--home",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"put",
|
||||
"AAAAAAAAAAAAA",
|
||||
nodeFile,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
let exitCode = 0,
|
||||
stderr = "";
|
||||
try {
|
||||
execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, "put", "AAAAAAAAAAAAA", nodeFile],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
||||
},
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stderr?: string; status?: number };
|
||||
stderr = (err.stderr ?? "").trim();
|
||||
exitCode = err.status ?? 1;
|
||||
}
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash } from "@ocas/core";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(
|
||||
tmpdir(),
|
||||
`ocas-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
cliPath = join(import.meta.dirname, "../dist/index.js");
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function createTestNode(): Promise<Hash> {
|
||||
const store = await openFsStore(storePath);
|
||||
const aliases = bootstrap(store);
|
||||
const typeHash = aliases["@ocas/string"];
|
||||
if (!typeHash) throw new Error("@ocas/string not found");
|
||||
const hash = store.cas.put(typeHash, `test-value-${Math.random()}`);
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function readTags(target: Hash) {
|
||||
const store = await openFsStore(storePath);
|
||||
return store.tag.tags(target);
|
||||
}
|
||||
|
||||
describe("ocas tag", () => {
|
||||
test("Test 1: tag <hash> applies key:value tags and labels", async () => {
|
||||
const hash = await createTestNode();
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"tag",
|
||||
hash,
|
||||
"env:prod",
|
||||
"stable",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(Array.isArray(envelope.value)).toBe(true);
|
||||
const value = envelope.value as Array<{
|
||||
key: string;
|
||||
value: string | null;
|
||||
target: string;
|
||||
}>;
|
||||
const byKey = (k: string) => value.find((t) => t.key === k);
|
||||
expect(byKey("env")).toMatchObject({
|
||||
key: "env",
|
||||
value: "prod",
|
||||
target: hash,
|
||||
});
|
||||
expect(byKey("stable")).toMatchObject({
|
||||
key: "stable",
|
||||
value: null,
|
||||
target: hash,
|
||||
});
|
||||
|
||||
const tags = await readTags(hash);
|
||||
expect(tags.find((t) => t.key === "env")?.value).toBe("prod");
|
||||
expect(tags.find((t) => t.key === "stable")?.value).toBeNull();
|
||||
});
|
||||
|
||||
test("Test 2: tag @scope/name resolves variable to its value hash", async () => {
|
||||
const hash = await createTestNode();
|
||||
|
||||
await runCli("var", "set", "@user/foo", hash);
|
||||
const { exitCode } = await runCli("tag", "@user/foo", "reviewed");
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const tagsOnHash = await readTags(hash);
|
||||
expect(tagsOnHash.find((t) => t.key === "reviewed")).toBeDefined();
|
||||
});
|
||||
|
||||
test("Test 3: untag <hash> env removes tag by key", async () => {
|
||||
const hash = await createTestNode();
|
||||
|
||||
await runCli("tag", hash, "env:prod", "stable");
|
||||
const { stdout, exitCode } = await runCli("untag", hash, "env");
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
const keys = (envelope.value as Array<{ key: string }>).map((t) => t.key);
|
||||
expect(keys).toContain("stable");
|
||||
expect(keys).not.toContain("env");
|
||||
|
||||
const remaining = await readTags(hash);
|
||||
expect(remaining.map((t) => t.key)).toEqual(["stable"]);
|
||||
});
|
||||
|
||||
test("Test 4: untag accepts key:value form (uses key only)", async () => {
|
||||
const hash = await createTestNode();
|
||||
|
||||
await runCli("tag", hash, "env:prod");
|
||||
const { exitCode } = await runCli("untag", hash, "env:prod");
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(await readTags(hash)).toEqual([]);
|
||||
});
|
||||
|
||||
test("Test 5: untag removes labels", async () => {
|
||||
const hash = await createTestNode();
|
||||
|
||||
await runCli("tag", hash, "pinned");
|
||||
const { exitCode } = await runCli("untag", hash, "pinned");
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(await readTags(hash)).toEqual([]);
|
||||
});
|
||||
|
||||
test("Test 6: tag without tag args errors", async () => {
|
||||
const hash = await createTestNode();
|
||||
const { stderr, exitCode } = await runCli("tag", hash);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Usage: ocas tag <target> <tag>...");
|
||||
});
|
||||
|
||||
test("Test 7: tag with no args errors", async () => {
|
||||
const { stderr, exitCode } = await runCli("tag");
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("Test 8: untag missing args errors", async () => {
|
||||
const hash = await createTestNode();
|
||||
|
||||
const r1 = await runCli("untag");
|
||||
expect(r1.exitCode).not.toBe(0);
|
||||
|
||||
const r2 = await runCli("untag", hash);
|
||||
expect(r2.exitCode).not.toBe(0);
|
||||
expect(r2.stderr).toContain("Usage: ocas untag <target> <tag>...");
|
||||
});
|
||||
|
||||
test("Test 9: tag with unknown variable name errors", async () => {
|
||||
const { stderr, exitCode } = await runCli("tag", "@user/missing", "label");
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Schema not found: @user/missing");
|
||||
});
|
||||
|
||||
test("Test 10: var tag is removed", async () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@any/name",
|
||||
"--schema",
|
||||
"@ocas/string",
|
||||
"foo",
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Unknown var subcommand: tag");
|
||||
});
|
||||
|
||||
test("Test 11: envelope schema name is @ocas/output/tag and @ocas/output/untag", async () => {
|
||||
const hash = await createTestNode();
|
||||
const store = await openFsStore(storePath);
|
||||
const aliases = bootstrap(store);
|
||||
const tagSchemaHash = aliases["@ocas/output/tag"];
|
||||
const untagSchemaHash = aliases["@ocas/output/untag"];
|
||||
expect(tagSchemaHash).toBeDefined();
|
||||
expect(untagSchemaHash).toBeDefined();
|
||||
|
||||
const r1 = await runCli("tag", hash, "stable");
|
||||
const env1 = JSON.parse(r1.stdout);
|
||||
expect(env1.type).toBe(tagSchemaHash);
|
||||
|
||||
const r2 = await runCli("untag", hash, "stable");
|
||||
const env2 = JSON.parse(r2.stdout);
|
||||
expect(env2.type).toBe(untagSchemaHash);
|
||||
});
|
||||
|
||||
test("Test 12: idempotent re-tag updates existing key value", async () => {
|
||||
const hash = await createTestNode();
|
||||
|
||||
await runCli("tag", hash, "env:dev");
|
||||
await runCli("tag", hash, "env:prod");
|
||||
|
||||
const tags = await readTags(hash);
|
||||
const envTags = tags.filter((t) => t.key === "env");
|
||||
expect(envTags).toHaveLength(1);
|
||||
expect(envTags[0]?.value).toBe("prod");
|
||||
});
|
||||
|
||||
test("Test 15: bootstrap registers @ocas/output/tag and @ocas/output/untag", async () => {
|
||||
const store = await openFsStore(storePath);
|
||||
const aliases = bootstrap(store);
|
||||
expect(aliases["@ocas/output/tag"]).toBeDefined();
|
||||
expect(aliases["@ocas/output/untag"]).toBeDefined();
|
||||
|
||||
const tagVar = store.var.list({ exactName: "@ocas/output/tag" });
|
||||
expect(tagVar.length).toBeGreaterThan(0);
|
||||
|
||||
const untagVar = store.var.list({ exactName: "@ocas/output/untag" });
|
||||
expect(untagVar.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
// ---- Test helpers ----
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let varDbPath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -20,8 +20,7 @@ beforeEach(() => {
|
||||
`ocas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
cliPath = join(import.meta.dir, "../src/index.ts");
|
||||
cliPath = join(import.meta.dirname, "../dist/index.js");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
@@ -39,47 +38,37 @@ afterEach(() => {
|
||||
/**
|
||||
* Run CLI command and return stdout, stderr, and exit code
|
||||
*/
|
||||
async function runCli(...args: string[]): Promise<{
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--home",
|
||||
storePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
...args,
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: proc.exitCode ?? 0,
|
||||
};
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bootstrap @string type hash
|
||||
* Get bootstrap @ocas/string type hash
|
||||
*/
|
||||
async function getStringHash(store: Store): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
return builtinSchemas["@ocas/string"] ?? "";
|
||||
}
|
||||
|
||||
@@ -87,7 +76,7 @@ async function getStringHash(store: Store): Promise<Hash> {
|
||||
|
||||
describe("template set", () => {
|
||||
test("set template from file", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
@@ -110,7 +99,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("set template with --inline flag", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
@@ -130,7 +119,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("update existing template (idempotent)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
@@ -159,7 +148,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("error when file not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
@@ -189,7 +178,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("error when both file and --inline provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
@@ -209,7 +198,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("support multi-line templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const multilineContent = "Line 1\nLine 2\nLine 3";
|
||||
@@ -229,7 +218,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("support empty templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
@@ -247,7 +236,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("error when neither file nor --inline provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "set", stringHash);
|
||||
@@ -257,7 +246,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("support templates with special characters", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const specialContent = "Template with {{var}} and $env and @ref";
|
||||
@@ -279,7 +268,7 @@ describe("template set", () => {
|
||||
|
||||
describe("template get", () => {
|
||||
test("retrieve template as envelope value", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Hello {{name}}!";
|
||||
@@ -299,7 +288,7 @@ describe("template get", () => {
|
||||
});
|
||||
|
||||
test("error when template not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "get", stringHash);
|
||||
@@ -310,7 +299,7 @@ describe("template get", () => {
|
||||
});
|
||||
|
||||
test("preserve exact whitespace", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// The envelope's value preserves exact whitespace (JSON-escaped),
|
||||
@@ -324,7 +313,7 @@ describe("template get", () => {
|
||||
});
|
||||
|
||||
test("support multi-line templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const multiline = "Line 1\nLine 2\nLine 3";
|
||||
@@ -338,7 +327,7 @@ describe("template get", () => {
|
||||
|
||||
describe("template list", () => {
|
||||
test("list all templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create multiple templates
|
||||
@@ -361,7 +350,7 @@ describe("template list", () => {
|
||||
});
|
||||
|
||||
test("entry contentHash matches set result", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout: setOut } = await runCli(
|
||||
@@ -397,14 +386,14 @@ describe("template list", () => {
|
||||
});
|
||||
|
||||
test("exclude non-template variables", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create a template
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
// Create a regular variable (not under @ocas/template/text/)
|
||||
const hash = await store.put(stringHash, "regular var content");
|
||||
const hash = store.cas.put(stringHash, "regular var content");
|
||||
await runCli("var", "set", "regular/var", hash);
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
@@ -417,7 +406,7 @@ describe("template list", () => {
|
||||
});
|
||||
|
||||
test("output JSON envelope with array value", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Test");
|
||||
@@ -434,7 +423,7 @@ describe("template list", () => {
|
||||
|
||||
describe("template delete", () => {
|
||||
test("delete template variable binding", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
@@ -463,7 +452,7 @@ describe("template delete", () => {
|
||||
});
|
||||
|
||||
test("error when template not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "delete", stringHash);
|
||||
@@ -474,7 +463,7 @@ describe("template delete", () => {
|
||||
});
|
||||
|
||||
test("deletion does not affect other templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create two templates
|
||||
@@ -497,7 +486,7 @@ describe("template delete", () => {
|
||||
});
|
||||
|
||||
test("CAS content remains after variable deletion", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Content");
|
||||
@@ -521,7 +510,7 @@ describe("template delete", () => {
|
||||
});
|
||||
|
||||
test("deletion is non-idempotent (second delete fails)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
@@ -546,7 +535,7 @@ describe("template delete", () => {
|
||||
|
||||
describe("template integration", () => {
|
||||
test("end-to-end workflow: set→get→list→delete", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Integration test template";
|
||||
@@ -593,7 +582,7 @@ describe("template integration", () => {
|
||||
});
|
||||
|
||||
test("templates compatible with generic var commands", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Set via template command
|
||||
@@ -607,7 +596,7 @@ describe("template integration", () => {
|
||||
});
|
||||
|
||||
test("multiple templates for different schemas", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create templates for different schemas
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const usagePath = join(import.meta.dirname, "..", "prompts", "usage.md");
|
||||
|
||||
describe("usage.md doc cleanup (D)", () => {
|
||||
test("D3. usage.md does not reference legacy openStoreAndVarStore / createVariableStore", () => {
|
||||
const content = readFileSync(usagePath, "utf8");
|
||||
expect(content).not.toContain("openStoreAndVarStore");
|
||||
expect(content).not.toContain("createVariableStore");
|
||||
});
|
||||
|
||||
test("D1. usage.md references openStore returning Store", () => {
|
||||
const content = readFileSync(usagePath, "utf8");
|
||||
expect(content).toContain("openStore");
|
||||
expect(content).toMatch(/store\.cas|store\.var|store\.tag/);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let varDbPath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -17,8 +17,7 @@ beforeEach(() => {
|
||||
`ocas-history-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
cliPath = join(import.meta.dir, "../src/index.ts");
|
||||
cliPath = join(import.meta.dirname, "../dist/index.js");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
@@ -32,52 +31,42 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
async function runCli(...args: string[]): Promise<{
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--home",
|
||||
storePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
...args,
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: proc.exitCode ?? 0,
|
||||
};
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function setupSchemaAndValues(): Promise<{
|
||||
schema: Hash;
|
||||
values: Hash[];
|
||||
}> {
|
||||
const store: Store = createFsStore(storePath);
|
||||
const aliases = await bootstrap(store);
|
||||
const store: Store = await openFsStore(storePath);
|
||||
const aliases = bootstrap(store);
|
||||
const numberHash = aliases["@ocas/number"] as Hash;
|
||||
const values: Hash[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
values.push((await store.put(numberHash, i)) as Hash);
|
||||
values.push(store.cas.put(numberHash, i) as Hash);
|
||||
}
|
||||
return { schema: numberHash, values };
|
||||
}
|
||||
|
||||
+133
-351
@@ -1,16 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import { bootstrap, putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
// ---- Test helpers ----
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let varDbPath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -20,8 +20,7 @@ beforeEach(() => {
|
||||
`ocas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
cliPath = join(import.meta.dir, "../src/index.ts");
|
||||
cliPath = join(import.meta.dirname, "../dist/index.js");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
@@ -39,40 +38,30 @@ afterEach(() => {
|
||||
/**
|
||||
* Run CLI command and return stdout, stderr, and exit code
|
||||
*/
|
||||
async function runCli(...args: string[]): Promise<{
|
||||
function runCli(...rawArgs: (string | string[])[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--home",
|
||||
storePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
...args,
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: proc.exitCode ?? 0,
|
||||
};
|
||||
} {
|
||||
const args = rawArgs.flat();
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,14 +72,14 @@ async function createTestNode(
|
||||
typeHash: Hash,
|
||||
payload: unknown,
|
||||
): Promise<Hash> {
|
||||
return await store.put(typeHash, payload);
|
||||
return store.cas.put(typeHash, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bootstrap type hash
|
||||
*/
|
||||
async function getBootstrapHash(store: Store): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
return builtinSchemas["@ocas/schema"] ?? "";
|
||||
}
|
||||
|
||||
@@ -98,7 +87,7 @@ async function getBootstrapHash(store: Store): Promise<Hash> {
|
||||
|
||||
describe("var set", () => {
|
||||
test("create new variable without tags/labels", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -125,7 +114,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("create with tags", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -148,7 +137,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("create with labels", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -171,7 +160,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("update existing variable (same schema)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
@@ -183,7 +172,12 @@ describe("var set", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Update with hash2
|
||||
const { stdout, exitCode } = await runCli("var", "set", "@test/config", hash2);
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"@test/config",
|
||||
hash2,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -193,9 +187,9 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("create variant with different schema", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash1 = await getBootstrapHash(store);
|
||||
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
|
||||
const typeHash2 = putSchema(store, { title: "Test", type: "object" });
|
||||
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
|
||||
|
||||
@@ -203,7 +197,12 @@ describe("var set", () => {
|
||||
await runCli("var", "set", "@test/config", hash1);
|
||||
|
||||
// Create second variant with different schema
|
||||
const { stdout, exitCode } = await runCli("var", "set", "@test/config", hash2);
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"@test/config",
|
||||
hash2,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -217,7 +216,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("update with new tags replaces old tags", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -254,7 +253,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("error on invalid name format", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -280,7 +279,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("error on tag/label name conflict", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -302,7 +301,7 @@ describe("var set", () => {
|
||||
|
||||
describe("var get", () => {
|
||||
test("retrieve existing variable by name + schema", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -326,7 +325,7 @@ describe("var get", () => {
|
||||
});
|
||||
|
||||
test("error when variable not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
@@ -353,9 +352,9 @@ describe("var get", () => {
|
||||
});
|
||||
|
||||
test("distinguish variants by schema", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash1 = await getBootstrapHash(store);
|
||||
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
|
||||
const typeHash2 = putSchema(store, { title: "Test", type: "object" });
|
||||
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
|
||||
|
||||
@@ -364,13 +363,25 @@ describe("var get", () => {
|
||||
await runCli("var", "set", "@test/config", hash2);
|
||||
|
||||
// Get first variant
|
||||
const result1 = await runCli("var", "get", "@test/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", "@test/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);
|
||||
@@ -379,9 +390,9 @@ describe("var get", () => {
|
||||
|
||||
describe("var delete", () => {
|
||||
test("remove all schema variants", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash1 = await getBootstrapHash(store);
|
||||
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
|
||||
const typeHash2 = putSchema(store, { title: "Test", type: "object" });
|
||||
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
|
||||
|
||||
@@ -399,17 +410,29 @@ describe("var delete", () => {
|
||||
expect(envelope.value.length).toBe(2);
|
||||
|
||||
// Verify both are deleted
|
||||
const result1 = await runCli("var", "get", "@test/config", "--schema", typeHash1);
|
||||
const result1 = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
typeHash1,
|
||||
);
|
||||
expect(result1.exitCode).toBe(1);
|
||||
|
||||
const result2 = await runCli("var", "get", "@test/config", "--schema", typeHash2);
|
||||
const result2 = await runCli(
|
||||
"var",
|
||||
"get",
|
||||
"@test/config",
|
||||
"--schema",
|
||||
typeHash2,
|
||||
);
|
||||
expect(result2.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("remove specific variant by schema", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash1 = await getBootstrapHash(store);
|
||||
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
|
||||
const typeHash2 = putSchema(store, { title: "Test", type: "object" });
|
||||
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
|
||||
|
||||
@@ -432,16 +455,32 @@ describe("var delete", () => {
|
||||
expect(envelope.value.schema).toBe(typeHash1);
|
||||
|
||||
// Verify first is deleted
|
||||
const result1 = await runCli("var", "get", "@test/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", "@test/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", "@test/nonexistent");
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"delete",
|
||||
"@test/nonexistent",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -466,7 +505,7 @@ describe("var delete", () => {
|
||||
});
|
||||
|
||||
test("cascade delete tags and labels", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -492,7 +531,7 @@ describe("var delete", () => {
|
||||
|
||||
describe("var list", () => {
|
||||
test("list all variables", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
@@ -515,7 +554,7 @@ describe("var list", () => {
|
||||
});
|
||||
|
||||
test("filter by name prefix", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
@@ -538,13 +577,13 @@ describe("var list", () => {
|
||||
});
|
||||
|
||||
test("filter by schema", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const bootstrapHash = await getBootstrapHash(store);
|
||||
const typeHash1 = await putSchema(store, {
|
||||
const typeHash1 = putSchema(store, {
|
||||
title: "TypeA",
|
||||
type: "object",
|
||||
});
|
||||
const typeHash2 = await putSchema(store, {
|
||||
const typeHash2 = putSchema(store, {
|
||||
title: "TypeB",
|
||||
type: "object",
|
||||
});
|
||||
@@ -573,7 +612,7 @@ describe("var list", () => {
|
||||
});
|
||||
|
||||
test("filter by tags (AND logic)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
@@ -620,7 +659,7 @@ describe("var list", () => {
|
||||
});
|
||||
|
||||
test("filter by labels", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
@@ -660,7 +699,7 @@ describe("var list", () => {
|
||||
});
|
||||
|
||||
test("combined filters", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash1 = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash1, { test: "data2" });
|
||||
@@ -713,235 +752,9 @@ describe("var list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("var tag", () => {
|
||||
test("add new tag", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable without tags
|
||||
await runCli("var", "set", "@test/x", hash);
|
||||
|
||||
// Add tag
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"env:prod",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.tags).toEqual({ env: "prod" });
|
||||
});
|
||||
|
||||
test("update existing tag value", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable with tag
|
||||
await runCli("var", "set", "@test/x", hash, "--tag", "env:dev");
|
||||
|
||||
// Update tag
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"env:prod",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.tags).toEqual({ env: "prod" });
|
||||
});
|
||||
|
||||
test("add label", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable without labels
|
||||
await runCli("var", "set", "@test/x", hash);
|
||||
|
||||
// Add label
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"stable",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.labels).toEqual(["stable"]);
|
||||
});
|
||||
|
||||
test("delete tag", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable with tags
|
||||
await runCli(
|
||||
"var",
|
||||
"set",
|
||||
"@test/x",
|
||||
hash,
|
||||
"--tag",
|
||||
"env:prod",
|
||||
"--tag",
|
||||
"version:1.0",
|
||||
);
|
||||
|
||||
// Delete tag
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
":env",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.tags).toEqual({ version: "1.0" });
|
||||
});
|
||||
|
||||
test("delete label", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable with labels
|
||||
await runCli("var", "set", "@test/x", hash, "--tag", "stable", "--tag", "beta");
|
||||
|
||||
// Delete label
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
":stable",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.labels).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
test("mixed add and delete operations", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable with tags and labels
|
||||
await runCli("var", "set", "@test/x", hash, "--tag", "a:1", "--tag", "b");
|
||||
|
||||
// Mixed operations
|
||||
const { stdout, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"c:3",
|
||||
":a",
|
||||
"d",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const envelope = JSON.parse(stdout);
|
||||
expect(envelope.value.tags).toEqual({ c: "3" });
|
||||
expect(envelope.value.labels.sort()).toEqual(["b", "d"]);
|
||||
});
|
||||
|
||||
test("error on tag/label conflict", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Create variable with tag
|
||||
await runCli("var", "set", "@test/x", hash, "--tag", "env:prod");
|
||||
|
||||
// Try to add same name as label
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"env",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Error: Conflict: 'env' already exists as a");
|
||||
});
|
||||
|
||||
test("error when variable not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@test/nonexistent",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"env:prod",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain(
|
||||
`Error: Variable not found: name=@test/nonexistent, schema=${typeHash}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("error when --schema missing", async () => {
|
||||
const { stderr, exitCode } = await runCli("var", "tag", "@test/x", "env:prod");
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain(
|
||||
"Usage: ocas var tag <name> --schema <hash-or-name> <operations...>",
|
||||
);
|
||||
});
|
||||
|
||||
test("error when no operations provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@test/x",
|
||||
"--schema",
|
||||
typeHash,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain(
|
||||
"Usage: ocas var tag <name> --schema <hash-or-name> <operations...>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("global options", () => {
|
||||
test("--json flag for compact output", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -965,64 +778,19 @@ describe("global options", () => {
|
||||
const customStorePath = join(testDir, "custom-store");
|
||||
mkdirSync(customStorePath, { recursive: true });
|
||||
|
||||
const store = createFsStore(customStorePath);
|
||||
const store = await openFsStore(customStorePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Override with custom store path
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--home",
|
||||
customStorePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"var",
|
||||
"set",
|
||||
"@test/x",
|
||||
hash,
|
||||
],
|
||||
execFileSync(
|
||||
"node",
|
||||
[cliPath, "--home", customStorePath, "var", "set", "@test/x", hash],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
await proc.exited;
|
||||
expect(proc.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("--var-db flag for custom database path", async () => {
|
||||
const customDbPath = join(testDir, "custom.db");
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Override with custom db path
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--home",
|
||||
storePath,
|
||||
"--var-db",
|
||||
customDbPath,
|
||||
"var",
|
||||
"set",
|
||||
"@test/x",
|
||||
hash,
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
await proc.exited;
|
||||
expect(proc.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1047,4 +815,18 @@ describe("old commands removed", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Unknown var subcommand: update");
|
||||
});
|
||||
|
||||
test("var tag subcommand removed", async () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
"var",
|
||||
"tag",
|
||||
"@any/name",
|
||||
"--schema",
|
||||
"@ocas/string",
|
||||
"foo",
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Unknown var subcommand: tag");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { envValue, stripVolatile } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -28,10 +27,7 @@ beforeAll(async () => {
|
||||
const { openStore: openFsStore } = await import("@ocas/fs");
|
||||
const { putSchema } = await import("@ocas/core");
|
||||
const store = await openFsStore(tmpStore);
|
||||
typeHash = await putSchema(
|
||||
store,
|
||||
JSON.parse(readFileSync(schemaFile, "utf-8")),
|
||||
);
|
||||
typeHash = putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
|
||||
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
@@ -43,17 +39,29 @@ afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
return { stdout, stderr, exitCode };
|
||||
function runCli(args: string[]): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
} {
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
"node",
|
||||
[entrypoint, "--home", tmpStore, ...args],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: (err.stdout ?? "").trim(),
|
||||
stderr: (err.stderr ?? "").trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("Phase 1: CAS Core", () => {
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"references": [{ "path": "../core" }, { "path": "../fs" }]
|
||||
}
|
||||
|
||||
+34
-29
@@ -1,47 +1,52 @@
|
||||
# @uncaged/json-cas
|
||||
# @ocas/core
|
||||
|
||||
## 0.6.0
|
||||
## 0.4.1 — 2026-06-07
|
||||
|
||||
### Minor Changes
|
||||
- Fix `gc` failing to preserve nodes referenced via `oneOf` — `collectRefs()` now traverses `oneOf` branches alongside existing `anyOf`/`allOf`/`if-then-else` handling.
|
||||
|
||||
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||
## 0.4.0 — 2026-06-07
|
||||
|
||||
### Breaking Changes
|
||||
- New `computeClosure(store, roots)` — traverses references and schema chains to gather a complete CAS closure.
|
||||
- New `exportBundle()` / `importBundle()` / `loadBundleStore()` — produce and consume self-contained POSIX-tar bundles (`cas/*.bin` CBOR payloads, `vars.jsonl`, `tags.jsonl`).
|
||||
- New builtin output schemas: `@ocas/output/export`, `@ocas/output/import`.
|
||||
|
||||
- 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
|
||||
## 0.3.0 — 2026-06-03
|
||||
|
||||
### New Features
|
||||
- No API changes. Coordinated version bump with `@ocas/fs` 0.3.0.
|
||||
|
||||
- 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.2.2 — 2026-06-03
|
||||
|
||||
## 0.5.3
|
||||
- Lint and format fixes.
|
||||
|
||||
### Patch Changes
|
||||
## 0.2.1 — 2026-06-03
|
||||
|
||||
- feat: add oneOf support to meta-schema validation
|
||||
- Migrate runtime from Bun to Node.js + pnpm.
|
||||
- Migrate test framework from `bun:test` to Vitest.
|
||||
- Extract `VariableStore` SQLite implementation to `@ocas/fs`.
|
||||
|
||||
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.2.0 — 2026-06-02
|
||||
|
||||
## 0.3.0
|
||||
### Breaking Changes
|
||||
|
||||
### Minor Changes
|
||||
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties.
|
||||
- `bootstrap(store)` and `putSchema(store, schema)` are now synchronous.
|
||||
- `VariableStore` class removed — SQLite implementation moved to `@ocas/fs`.
|
||||
- `createVariableStore()` removed.
|
||||
- Zero `bun:sqlite` imports — pure TypeScript.
|
||||
|
||||
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
|
||||
### New Features
|
||||
|
||||
## 0.2.0
|
||||
- `CasStore`, `VarStore`, `TagStore` sub-store types.
|
||||
- `validation.ts` — shared `validateName()` exported from core.
|
||||
|
||||
### Minor Changes
|
||||
## 0.1.2 — 2026-06-02
|
||||
|
||||
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
|
||||
- Fix TypeScript LSP errors: tsconfig `noEmit`, `exactOptionalPropertyTypes` conditional spread, `schemaHash` scope.
|
||||
|
||||
## 0.1.3
|
||||
## 0.1.1 — 2026-06-02
|
||||
|
||||
- Internal improvements.
|
||||
|
||||
## 0.1.0 — 2026-06-01
|
||||
|
||||
Initial release. Content-addressable store engine with JSON Schema typed nodes, XXH64 hashing, variable store, render with resolution decay, and LiquidJS template support.
|
||||
|
||||
+48
-9
@@ -6,14 +6,14 @@ Core CAS engine — hashing, schema, store, verify, bootstrap.
|
||||
|
||||
`@ocas/core` is the foundation of the ocas monorepo. It defines content-addressed nodes (`CasNode`), the `Store` interface, XXH64-based hashing with deterministic CBOR, JSON Schema registration and validation (including `cas_ref` links between nodes), bootstrap seeding, and integrity verification.
|
||||
|
||||
Other packages build on this layer: `ocas-fs` provides persistence, and `cli-ocas` exposes store operations on the command line.
|
||||
Other packages build on this layer: `@ocas/fs` provides persistence, and `@ocas/cli` exposes store operations on the command line.
|
||||
|
||||
**Dependencies:** `ajv`, `cborg`, `xxhash-wasm`
|
||||
**Dependencies:** `ajv`, `cborg`, `liquidjs`, `xxhash-wasm`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @ocas/core
|
||||
pnpm add @ocas/core
|
||||
```
|
||||
|
||||
## API
|
||||
@@ -32,12 +32,7 @@ type CasNode<T = unknown> = {
|
||||
timestamp: number; // Unix epoch ms
|
||||
};
|
||||
|
||||
type Store = {
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash>;
|
||||
get(hash: Hash): CasNode | null;
|
||||
has(hash: Hash): boolean;
|
||||
listByType(typeHash: Hash): Hash[];
|
||||
};
|
||||
type Store = { cas: CasStore; var: VarStore; tag: TagStore; };
|
||||
|
||||
type JSONSchema = Record<string, unknown>;
|
||||
|
||||
@@ -104,6 +99,48 @@ async function verify(hash: Hash, node: CasNode): Promise<boolean>;
|
||||
|
||||
Recomputes hash from `node` and compares to `hash` (self-referencing vs normal rules).
|
||||
|
||||
### Closure & Bundles
|
||||
|
||||
```typescript
|
||||
type ClosureResult = {
|
||||
nodes: Set<Hash>;
|
||||
vars: Variable[];
|
||||
tags: Map<Hash, Tag[]>;
|
||||
};
|
||||
function computeClosure(store: Store, roots: Hash[]): ClosureResult;
|
||||
|
||||
type ExportStats = { nodes: number; vars: number; tags: number };
|
||||
type ImportOptions = { scope?: string };
|
||||
type ImportStats = {
|
||||
nodes: { imported: number; skipped: number };
|
||||
vars: { created: number; updated: number };
|
||||
tags: number;
|
||||
};
|
||||
async function exportBundle(
|
||||
store: Store,
|
||||
roots: Hash[],
|
||||
outputPath: string,
|
||||
): Promise<ExportStats>;
|
||||
async function importBundle(
|
||||
bundlePath: string,
|
||||
target: Store,
|
||||
options?: ImportOptions,
|
||||
): Promise<ImportStats>;
|
||||
async function loadBundleStore(bundlePath: string): Promise<Store>;
|
||||
```
|
||||
|
||||
- `computeClosure` — walks `cas_ref` edges and schema chains from each root,
|
||||
also gathering every `Variable` whose `value` lands in the closure and every
|
||||
`Tag` attached to an in-closure target.
|
||||
- `exportBundle` — writes a self-contained POSIX-tar archive containing
|
||||
`cas/<hash>.bin` (CBOR-encoded payloads), `vars.jsonl`, and `tags.jsonl`.
|
||||
- `importBundle` — content-addressed merge into `target`. Idempotent:
|
||||
re-importing the same bundle yields zero `imported` and zero `created`.
|
||||
`options.scope` rewrites the leading `@scope/` of every imported variable
|
||||
name except `@ocas/*` builtins.
|
||||
- `loadBundleStore` — convenience that returns an in-memory `Store` populated
|
||||
from a bundle (for read-only inspection without touching the persistent store).
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
@@ -154,6 +191,8 @@ walk(store, bobHash, (h) => console.log(h)); // bobHash, aliceHash
|
||||
| `mem-store.ts` | Alternate in-memory store (tests only; not exported) |
|
||||
| `schema.ts` | Schema put/get/validate, `refs`, `walk` |
|
||||
| `verify.ts` | Node integrity verification |
|
||||
| `closure.ts` | `computeClosure` — refs + schema chain traversal |
|
||||
| `bundle.ts` | `exportBundle`, `importBundle`, `loadBundleStore` (POSIX tar) |
|
||||
| `index.ts` | Public exports |
|
||||
|
||||
Tests live in `src/*.test.ts` and `tests/`.
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
{
|
||||
"name": "@ocas/core",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Core CAS engine — hashing, schema, store, verify, bootstrap",
|
||||
"keywords": [
|
||||
"cas",
|
||||
"content-addressing",
|
||||
"json-schema",
|
||||
"typescript"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -14,10 +24,6 @@
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
@@ -32,5 +38,8 @@
|
||||
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/core",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { CasStore, Hash } from "./types.js";
|
||||
|
||||
/** @internal Store implementations attach this for bootstrap() only. */
|
||||
export const BOOTSTRAP_STORE = Symbol.for("@ocas/core/bootstrap-store");
|
||||
|
||||
export type BootstrapCapableStore = Store & {
|
||||
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash>;
|
||||
export type BootstrapCapableStore = CasStore & {
|
||||
[BOOTSTRAP_STORE](payload: unknown): Hash;
|
||||
};
|
||||
|
||||
export function isBootstrapCapableStore(
|
||||
store: Store,
|
||||
store: CasStore,
|
||||
): store is BootstrapCapableStore {
|
||||
return (
|
||||
typeof (store as BootstrapCapableStore)[BOOTSTRAP_STORE] === "function"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import type { JSONSchema } from "./schema.js";
|
||||
import { getSchema } from "./schema.js";
|
||||
@@ -18,14 +18,17 @@ const OUTPUT_ALIASES = [
|
||||
"@ocas/output/var-set",
|
||||
"@ocas/output/var-get",
|
||||
"@ocas/output/var-delete",
|
||||
"@ocas/output/var-tag",
|
||||
"@ocas/output/var-list",
|
||||
"@ocas/output/var-history",
|
||||
"@ocas/output/tag",
|
||||
"@ocas/output/untag",
|
||||
"@ocas/output/template-set",
|
||||
"@ocas/output/template-get",
|
||||
"@ocas/output/template-list",
|
||||
"@ocas/output/template-delete",
|
||||
"@ocas/output/gc",
|
||||
"@ocas/output/export",
|
||||
"@ocas/output/import",
|
||||
] as const;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -33,11 +36,11 @@ const OUTPUT_ALIASES = [
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bootstrap - Built-in Schemas", () => {
|
||||
test("should return map of 30 built-in schema aliases to hashes", async () => {
|
||||
test("should return map of 33 built-in schema aliases to hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
|
||||
// Should return object with 9 primitive + 21 output aliases = 30
|
||||
// Should return object with 9 primitive + 24 output aliases = 33
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/schema");
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/string");
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/number");
|
||||
@@ -52,7 +55,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
expect(builtinSchemas).toHaveProperty(alias);
|
||||
}
|
||||
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(30);
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(33);
|
||||
|
||||
// All values should be valid hashes
|
||||
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
||||
@@ -61,12 +64,12 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should register @schema as meta-schema alias", async () => {
|
||||
test("should register @ocas/schema as meta-schema alias", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
|
||||
const metaHash = builtinSchemas["@ocas/schema"];
|
||||
if (!metaHash) throw new Error("@schema not found");
|
||||
if (!metaHash) throw new Error("@ocas/schema not found");
|
||||
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
expect(metaSchema).not.toBeNull();
|
||||
@@ -74,45 +77,45 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
expect(metaSchema?.description).toBe("ocas JSON Schema meta-schema");
|
||||
});
|
||||
|
||||
test("should register @string schema correctly", async () => {
|
||||
test("should register @ocas/string schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
|
||||
const stringHash = builtinSchemas["@ocas/string"];
|
||||
if (!stringHash) throw new Error("@string not found");
|
||||
if (!stringHash) throw new Error("@ocas/string not found");
|
||||
|
||||
const stringSchema = getSchema(store, stringHash);
|
||||
expect(stringSchema).toEqual({ type: "string" });
|
||||
});
|
||||
|
||||
test("should register @number schema correctly", async () => {
|
||||
test("should register @ocas/number schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
|
||||
const numberHash = builtinSchemas["@ocas/number"];
|
||||
if (!numberHash) throw new Error("@number not found");
|
||||
if (!numberHash) throw new Error("@ocas/number not found");
|
||||
|
||||
const numberSchema = getSchema(store, numberHash);
|
||||
expect(numberSchema).toEqual({ type: "number" });
|
||||
});
|
||||
|
||||
test("should register @object schema correctly", async () => {
|
||||
test("should register @ocas/object schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
|
||||
const objectHash = builtinSchemas["@ocas/object"];
|
||||
if (!objectHash) throw new Error("@object not found");
|
||||
if (!objectHash) throw new Error("@ocas/object not found");
|
||||
|
||||
const objectSchema = getSchema(store, objectHash);
|
||||
expect(objectSchema).toEqual({ type: "object" });
|
||||
});
|
||||
|
||||
test("should register @array schema correctly", async () => {
|
||||
test("should register @ocas/array schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
|
||||
const arrayHash = builtinSchemas["@ocas/array"];
|
||||
if (!arrayHash) throw new Error("@array not found");
|
||||
if (!arrayHash) throw new Error("@ocas/array not found");
|
||||
|
||||
const arraySchema = getSchema(store, arrayHash);
|
||||
expect(arraySchema).toEqual({ type: "array" });
|
||||
@@ -120,7 +123,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
|
||||
test("should register @ocas/bool schema correctly", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
|
||||
const boolHash = builtinSchemas["@ocas/bool"];
|
||||
if (!boolHash) throw new Error("@ocas/bool not found");
|
||||
@@ -131,8 +134,8 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
|
||||
test("should return same hashes on repeated bootstrap calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const first = await bootstrap(store);
|
||||
const second = await bootstrap(store);
|
||||
const first = bootstrap(store);
|
||||
const second = bootstrap(store);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
|
||||
@@ -147,15 +150,15 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
|
||||
test("all built-in schemas should be typed by meta-schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
|
||||
const metaHash = builtinSchemas["@ocas/schema"];
|
||||
if (!metaHash) throw new Error("@schema not found");
|
||||
if (!metaHash) throw new Error("@ocas/schema not found");
|
||||
|
||||
for (const [alias, hash] of Object.entries(builtinSchemas)) {
|
||||
if (alias === "@ocas/schema") continue; // meta-schema is self-typed
|
||||
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.type).toBe(metaHash);
|
||||
}
|
||||
@@ -169,7 +172,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
test("each @ocas/output/* schema has a title", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
|
||||
for (const alias of OUTPUT_ALIASES) {
|
||||
const hash = aliases[alias];
|
||||
@@ -184,7 +187,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
test("@ocas/output/put schema describes a ocas_ref string", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const hash = aliases["@ocas/output/put"];
|
||||
if (!hash) throw new Error("@ocas/output/put not found");
|
||||
|
||||
@@ -198,7 +201,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
test("@ocas/output/get schema describes object with type, payload, timestamp", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const hash = aliases["@ocas/output/get"];
|
||||
if (!hash) throw new Error("@ocas/output/get not found");
|
||||
|
||||
@@ -214,7 +217,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
test("@ocas/output/has schema describes a boolean", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const hash = aliases["@ocas/output/has"];
|
||||
if (!hash) throw new Error("@ocas/output/has not found");
|
||||
|
||||
@@ -226,7 +229,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
test("@ocas/output/verify schema describes enum of ok|corrupted|invalid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const hash = aliases["@ocas/output/verify"];
|
||||
if (!hash) throw new Error("@ocas/output/verify not found");
|
||||
|
||||
@@ -240,7 +243,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
test("@ocas/output/refs schema describes array of ocas_ref strings", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const hash = aliases["@ocas/output/refs"];
|
||||
if (!hash) throw new Error("@ocas/output/refs not found");
|
||||
|
||||
@@ -253,7 +256,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
test("@ocas/output/gc schema describes object with gc stats fields", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const hash = aliases["@ocas/output/gc"];
|
||||
if (!hash) throw new Error("@ocas/output/gc not found");
|
||||
|
||||
@@ -270,7 +273,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
test("@ocas/output/var-set schema describes a Variable object", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const hash = aliases["@ocas/output/var-set"];
|
||||
if (!hash) throw new Error("@ocas/output/var-set not found");
|
||||
|
||||
@@ -286,7 +289,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
test("@ocas/output/var-list schema describes array of Variable objects", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const hash = aliases["@ocas/output/var-list"];
|
||||
if (!hash) throw new Error("@ocas/output/var-list not found");
|
||||
|
||||
@@ -302,7 +305,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
test("@ocas/output/template-delete schema describes object with deleted boolean", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const hash = aliases["@ocas/output/template-delete"];
|
||||
if (!hash) throw new Error("@ocas/output/template-delete not found");
|
||||
|
||||
@@ -315,7 +318,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
test("all @ocas/output/* schemas are distinct hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
|
||||
const outputHashes = OUTPUT_ALIASES.map((alias) => aliases[alias]);
|
||||
const uniqueHashes = new Set(outputHashes);
|
||||
@@ -326,15 +329,17 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
describe("bootstrap - meta and schemas indexes (D1)", () => {
|
||||
test("listMeta contains the bootstrap meta-schema hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const metaHash = aliases["@ocas/schema"];
|
||||
expect(store.listMeta().map((e) => e.hash)).toContain(metaHash as string);
|
||||
expect(store.cas.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().map((e) => e.hash);
|
||||
const aliases = bootstrap(store);
|
||||
const schemas = store.cas.listSchemas().map((e) => e.hash);
|
||||
|
||||
for (const [, hash] of Object.entries(aliases)) {
|
||||
expect(schemas).toContain(hash);
|
||||
|
||||
+114
-34
@@ -3,7 +3,6 @@ import {
|
||||
isBootstrapCapableStore,
|
||||
} from "./bootstrap-capable.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
|
||||
const JSON_SCHEMA_TYPES = [
|
||||
"string",
|
||||
@@ -132,6 +131,18 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
type: { type: "string", format: "ocas_ref" },
|
||||
payload: {},
|
||||
timestamp: { type: "number" },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
value: { type: ["string", "null"] },
|
||||
target: { type: "string", format: "ocas_ref" },
|
||||
created: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
title: "ocas get result",
|
||||
},
|
||||
@@ -225,7 +236,21 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
"@ocas/output/var-get",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
properties: {
|
||||
...VARIABLE_PROPERTIES,
|
||||
valueTags: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
value: { type: ["string", "null"] },
|
||||
target: { type: "string", format: "ocas_ref" },
|
||||
created: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
title: "ocas var get result",
|
||||
},
|
||||
],
|
||||
@@ -237,14 +262,6 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
title: "ocas var delete result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ocas/output/var-tag",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ocas var tag result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ocas/output/var-list",
|
||||
{
|
||||
@@ -268,6 +285,38 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
title: "ocas var history result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ocas/output/tag",
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
value: { type: ["string", "null"] },
|
||||
target: { type: "string", format: "ocas_ref" },
|
||||
created: { type: "number" },
|
||||
},
|
||||
},
|
||||
title: "ocas tag result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ocas/output/untag",
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
value: { type: ["string", "null"] },
|
||||
target: { type: "string", format: "ocas_ref" },
|
||||
created: { type: "number" },
|
||||
},
|
||||
},
|
||||
title: "ocas untag result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ocas/output/template-set",
|
||||
{
|
||||
@@ -318,6 +367,42 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
title: "ocas gc result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ocas/output/export",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
nodes: { type: "number" },
|
||||
vars: { type: "number" },
|
||||
tags: { type: "number" },
|
||||
},
|
||||
title: "ocas export result",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ocas/output/import",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
nodes: {
|
||||
type: "object",
|
||||
properties: {
|
||||
imported: { type: "number" },
|
||||
skipped: { type: "number" },
|
||||
},
|
||||
},
|
||||
vars: {
|
||||
type: "object",
|
||||
properties: {
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
},
|
||||
},
|
||||
tags: { type: "number" },
|
||||
},
|
||||
title: "ocas import result",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -326,29 +411,26 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
* and @ocas/output/* schemas.
|
||||
* Idempotent: calling bootstrap multiple times returns the same hashes.
|
||||
*
|
||||
* If a varStore is provided, all aliases are also written to it via
|
||||
* varStore.set(name, hash). This bypasses @ocas/ namespace protection
|
||||
* (protection is enforced only at the CLI layer).
|
||||
* All aliases are written to `store.var` via `var.set(name, hash)`, bypassing
|
||||
* @ocas/ namespace protection (protection is enforced only at the CLI layer).
|
||||
*/
|
||||
export async function bootstrap(
|
||||
store: Store,
|
||||
varStore?: VariableStore,
|
||||
): Promise<Record<string, Hash>> {
|
||||
if (!isBootstrapCapableStore(store)) {
|
||||
export function bootstrap(store: Store): Record<string, Hash> {
|
||||
const cas = store.cas;
|
||||
if (!isBootstrapCapableStore(cas)) {
|
||||
throw new Error("Store does not support bootstrap");
|
||||
}
|
||||
|
||||
// 1. Bootstrap the meta-schema (self-referential)
|
||||
const metaHash = await store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
||||
const metaHash = cas[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
||||
|
||||
// 2. Register built-in primitive schemas directly (without putSchema to avoid recursion)
|
||||
const stringHash = await store.put(metaHash, { type: "string" });
|
||||
const numberHash = await store.put(metaHash, { type: "number" });
|
||||
const integerHash = await store.put(metaHash, { type: "integer" });
|
||||
const boolHash = await store.put(metaHash, { type: "boolean" });
|
||||
const objectHash = await store.put(metaHash, { type: "object" });
|
||||
const arrayHash = await store.put(metaHash, { type: "array" });
|
||||
const nullHash = await store.put(metaHash, { type: "null" });
|
||||
const stringHash = cas.put(metaHash, { type: "string" });
|
||||
const numberHash = cas.put(metaHash, { type: "number" });
|
||||
const integerHash = cas.put(metaHash, { type: "integer" });
|
||||
const boolHash = cas.put(metaHash, { type: "boolean" });
|
||||
const objectHash = cas.put(metaHash, { type: "object" });
|
||||
const arrayHash = cas.put(metaHash, { type: "array" });
|
||||
const nullHash = cas.put(metaHash, { type: "null" });
|
||||
|
||||
// 3. Register @ocas/output/* schemas
|
||||
const aliases: Record<string, Hash> = {
|
||||
@@ -364,16 +446,14 @@ export async function bootstrap(
|
||||
};
|
||||
|
||||
for (const [alias, schema] of OUTPUT_SCHEMAS) {
|
||||
aliases[alias] = await store.put(metaHash, schema);
|
||||
aliases[alias] = cas.put(metaHash, schema);
|
||||
}
|
||||
|
||||
// 4. Write all aliases to varStore (when provided).
|
||||
// Idempotent: VariableStore.set is an upsert. Bypasses @ocas/ namespace
|
||||
// protection — protection is only enforced on the CLI `var set` command.
|
||||
if (varStore !== undefined) {
|
||||
for (const [name, hash] of Object.entries(aliases)) {
|
||||
varStore.set(name, hash);
|
||||
}
|
||||
// 4. Write all aliases to the var store. Idempotent: VarStore.set is an
|
||||
// upsert. Bypasses @ocas/ namespace protection — protection is enforced
|
||||
// only on the CLI `var set` command.
|
||||
for (const [name, hash] of Object.entries(aliases)) {
|
||||
store.var.set(name, hash);
|
||||
}
|
||||
|
||||
return aliases;
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { exportBundle, importBundle, loadBundleStore } from "./bundle.js";
|
||||
import { cborEncode } from "./cbor.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "ocas-bundle-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("exportBundle / importBundle / loadBundleStore", () => {
|
||||
test("2.1 export: tar file structure includes cas/, vars.jsonl, tags.jsonl", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" } },
|
||||
});
|
||||
const aHash = store.cas.put(schemaHash, { x: 42 });
|
||||
store.var.set("@test/config", aHash);
|
||||
store.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
const stats = await exportBundle(store, ["@test/config"], out);
|
||||
|
||||
const buf = readFileSync(out);
|
||||
// Standard tar should have 512-byte aligned blocks.
|
||||
expect(buf.length % 512).toBe(0);
|
||||
|
||||
// Parse out the entry names from the tar.
|
||||
const names = listTarEntries(buf);
|
||||
expect(names.some((n) => n === `cas/${aHash}.bin`)).toBe(true);
|
||||
expect(names.some((n) => n === `cas/${schemaHash}.bin`)).toBe(true);
|
||||
expect(names).toContain("vars.jsonl");
|
||||
expect(names).toContain("tags.jsonl");
|
||||
|
||||
expect(stats.nodes).toBeGreaterThanOrEqual(2);
|
||||
expect(stats.vars).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.tags).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("2.2 export: CAS node binary identity is preserved", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
const schemaHash = putSchema(store, { type: "string" });
|
||||
const aHash = store.cas.put(schemaHash, "hello");
|
||||
store.var.set("@test/h", aHash);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(store, ["@test/h"], out);
|
||||
|
||||
const buf = readFileSync(out);
|
||||
const entries = readTarEntries(buf);
|
||||
const casEntry = entries.find((e) => e.name === `cas/${aHash}.bin`);
|
||||
expect(casEntry).toBeDefined();
|
||||
|
||||
const node = store.cas.get(aHash);
|
||||
expect(node).not.toBeNull();
|
||||
if (!node) return;
|
||||
const expected = cborEncode({
|
||||
type: node.type,
|
||||
payload: node.payload,
|
||||
timestamp: node.timestamp,
|
||||
});
|
||||
expect(casEntry?.content).toEqual(expected);
|
||||
});
|
||||
|
||||
test("2.3 export: vars.jsonl contains parseable JSON lines", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
const schemaHash = putSchema(store, { type: "string" });
|
||||
const aHash = store.cas.put(schemaHash, "a");
|
||||
const bHash = store.cas.put(schemaHash, "b");
|
||||
store.var.set("@test/a", aHash);
|
||||
store.var.set("@test/b", bHash);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(store, ["@test/a", "@test/b"], out);
|
||||
|
||||
const entries = readTarEntries(readFileSync(out));
|
||||
const vars = entries.find((e) => e.name === "vars.jsonl");
|
||||
expect(vars).toBeDefined();
|
||||
const text = new TextDecoder().decode(vars?.content);
|
||||
const lines = text.split("\n").filter((l) => l.length > 0);
|
||||
const records = lines.map(
|
||||
(l) => JSON.parse(l) as { name: string; value: string },
|
||||
);
|
||||
const names = records.map((r) => r.name);
|
||||
expect(names).toContain("@test/a");
|
||||
expect(names).toContain("@test/b");
|
||||
const aRec = records.find((r) => r.name === "@test/a");
|
||||
expect(aRec?.value).toBe(aHash);
|
||||
});
|
||||
|
||||
test("2.4 export: tags.jsonl contains target/key/value records", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
const schemaHash = putSchema(store, { type: "string" });
|
||||
const aHash = store.cas.put(schemaHash, "tagged");
|
||||
store.var.set("@test/t", aHash);
|
||||
store.tag.tag(aHash, [
|
||||
{ op: "set", key: "env", value: "prod" },
|
||||
{ op: "set", key: "stable" },
|
||||
]);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(store, ["@test/t"], out);
|
||||
|
||||
const entries = readTarEntries(readFileSync(out));
|
||||
const tagEntry = entries.find((e) => e.name === "tags.jsonl");
|
||||
expect(tagEntry).toBeDefined();
|
||||
const text = new TextDecoder().decode(tagEntry?.content);
|
||||
const lines = text.split("\n").filter((l) => l.length > 0);
|
||||
const records = lines.map(
|
||||
(l) =>
|
||||
JSON.parse(l) as {
|
||||
target: string;
|
||||
key: string;
|
||||
value: string | null;
|
||||
},
|
||||
);
|
||||
const env = records.find((r) => r.key === "env");
|
||||
expect(env?.value).toBe("prod");
|
||||
expect(env?.target).toBe(aHash);
|
||||
const stable = records.find((r) => r.key === "stable");
|
||||
expect(stable?.value).toBeNull();
|
||||
});
|
||||
|
||||
test("2.5 export: accepts variable names and raw hashes as roots", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
const schemaHash = putSchema(store, { type: "string" });
|
||||
const aHash = store.cas.put(schemaHash, "x");
|
||||
store.var.set("@test/c", aHash);
|
||||
|
||||
const out1 = join(tmpDir, "by-name.tar");
|
||||
const out2 = join(tmpDir, "by-hash.tar");
|
||||
await exportBundle(store, ["@test/c"], out1);
|
||||
await exportBundle(store, [aHash], out2);
|
||||
|
||||
const names1 = listTarEntries(readFileSync(out1));
|
||||
const names2 = listTarEntries(readFileSync(out2));
|
||||
expect(names1).toContain(`cas/${aHash}.bin`);
|
||||
expect(names2).toContain(`cas/${aHash}.bin`);
|
||||
});
|
||||
|
||||
test("2.6 export: non-existent root throws", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await expect(
|
||||
exportBundle(store, ["@test/nonexistent"], out),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("2.7 import: nodes are written to target store", async () => {
|
||||
const src = createMemoryStore();
|
||||
bootstrap(src);
|
||||
const schemaHash = putSchema(src, {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" } },
|
||||
});
|
||||
const aHash = src.cas.put(schemaHash, { x: 1 });
|
||||
src.var.set("@test/c", aHash);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(src, ["@test/c"], out);
|
||||
|
||||
const dst = createMemoryStore();
|
||||
bootstrap(dst);
|
||||
await importBundle(out, dst);
|
||||
|
||||
expect(dst.cas.has(aHash)).toBe(true);
|
||||
const node = dst.cas.get(aHash);
|
||||
expect(node?.type).toBe(schemaHash);
|
||||
expect(node?.payload).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
test("2.8 import: skip existing nodes (content-addressed dedup)", async () => {
|
||||
const src = createMemoryStore();
|
||||
bootstrap(src);
|
||||
const schemaHash = putSchema(src, { type: "string" });
|
||||
const aHash = src.cas.put(schemaHash, "a");
|
||||
src.var.set("@test/c", aHash);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(src, ["@test/c"], out);
|
||||
|
||||
const dst = createMemoryStore();
|
||||
bootstrap(dst);
|
||||
// Pre-populate destination with the same node
|
||||
dst.cas.put(schemaHash, "a"); // wait — schemaHash may not exist in dst
|
||||
// To deduplicate, we need to ensure the same hash is computed.
|
||||
// Re-import the schema first via import.
|
||||
const stats = await importBundle(out, dst);
|
||||
|
||||
// After two imports the second's nodes.skipped should equal nodes.imported of the first.
|
||||
const stats2 = await importBundle(out, dst);
|
||||
expect(stats2.nodes.skipped).toBeGreaterThan(0);
|
||||
expect(stats2.nodes.imported).toBe(0);
|
||||
void stats;
|
||||
});
|
||||
|
||||
test("2.9 import: variables created without scope use original names", async () => {
|
||||
const src = createMemoryStore();
|
||||
bootstrap(src);
|
||||
const schemaHash = putSchema(src, { type: "string" });
|
||||
const aHash = src.cas.put(schemaHash, "v");
|
||||
src.var.set("@test/config", aHash);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(src, ["@test/config"], out);
|
||||
|
||||
const dst = createMemoryStore();
|
||||
bootstrap(dst);
|
||||
await importBundle(out, dst);
|
||||
|
||||
const v = dst.var.get("@test/config");
|
||||
expect(v?.value).toBe(aHash);
|
||||
});
|
||||
|
||||
test("2.10 import: scope remapping rewrites variable names", async () => {
|
||||
const src = createMemoryStore();
|
||||
bootstrap(src);
|
||||
const schemaHash = putSchema(src, { type: "string" });
|
||||
const aHash = src.cas.put(schemaHash, "v");
|
||||
src.var.set("@test/config", aHash);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(src, ["@test/config"], out);
|
||||
|
||||
const dst = createMemoryStore();
|
||||
bootstrap(dst);
|
||||
await importBundle(out, dst, { scope: "@imported" });
|
||||
|
||||
const remapped = dst.var.get("@imported/config");
|
||||
expect(remapped?.value).toBe(aHash);
|
||||
const original = dst.var.get("@test/config");
|
||||
expect(original).toBeNull();
|
||||
});
|
||||
|
||||
test("2.11 import: @ocas/* builtin variables are NOT remapped", async () => {
|
||||
const src = createMemoryStore();
|
||||
bootstrap(src);
|
||||
const schemaHash = putSchema(src, { type: "string" });
|
||||
const aHash = src.cas.put(schemaHash, "v");
|
||||
src.var.set("@test/config", aHash);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(src, ["@test/config"], out);
|
||||
|
||||
const dst = createMemoryStore();
|
||||
bootstrap(dst);
|
||||
await importBundle(out, dst, { scope: "@imported" });
|
||||
|
||||
// @ocas/schema, @ocas/string etc. should still be reachable as-is.
|
||||
expect(dst.var.get("@ocas/schema")).not.toBeNull();
|
||||
// No variant under the remapped scope.
|
||||
expect(dst.var.get("@imported/schema")).toBeNull();
|
||||
});
|
||||
|
||||
test("2.12 import: variable conflict — overwrite with stats marking 'updated'", async () => {
|
||||
const src = createMemoryStore();
|
||||
bootstrap(src);
|
||||
const schemaHash = putSchema(src, { type: "string" });
|
||||
const aHash = src.cas.put(schemaHash, "imported");
|
||||
src.var.set("@test/config", aHash);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(src, ["@test/config"], out);
|
||||
|
||||
const dst = createMemoryStore();
|
||||
bootstrap(dst);
|
||||
// Pre-populate destination with same name → different value.
|
||||
const dstSchema = putSchema(dst, { type: "string" });
|
||||
const bHash = dst.cas.put(dstSchema, "preexisting");
|
||||
dst.var.set("@test/config", bHash);
|
||||
|
||||
const stats = await importBundle(out, dst);
|
||||
expect(stats.vars.updated).toBeGreaterThanOrEqual(1);
|
||||
// Value should now point at the imported hash.
|
||||
const v = dst.var.get("@test/config", schemaHash);
|
||||
expect(v?.value).toBe(aHash);
|
||||
});
|
||||
|
||||
test("2.13 import: tags are applied to imported nodes", async () => {
|
||||
const src = createMemoryStore();
|
||||
bootstrap(src);
|
||||
const schemaHash = putSchema(src, { type: "string" });
|
||||
const aHash = src.cas.put(schemaHash, "tagged");
|
||||
src.var.set("@test/c", aHash);
|
||||
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(src, ["@test/c"], out);
|
||||
|
||||
const dst = createMemoryStore();
|
||||
bootstrap(dst);
|
||||
await importBundle(out, dst);
|
||||
|
||||
const tags = dst.tag.tags(aHash);
|
||||
expect(tags.some((t) => t.key === "env" && t.value === "prod")).toBe(true);
|
||||
});
|
||||
|
||||
test("2.14 import: stats report nodes/vars/tags counts", async () => {
|
||||
const src = createMemoryStore();
|
||||
bootstrap(src);
|
||||
const schemaHash = putSchema(src, { type: "string" });
|
||||
const aHash = src.cas.put(schemaHash, "v");
|
||||
src.var.set("@test/c", aHash);
|
||||
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(src, ["@test/c"], out);
|
||||
|
||||
const dst = createMemoryStore();
|
||||
bootstrap(dst);
|
||||
const stats = await importBundle(out, dst);
|
||||
|
||||
expect(stats.nodes.imported).toBeGreaterThan(0);
|
||||
expect(stats.nodes.skipped).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.vars.created + stats.vars.updated).toBeGreaterThan(0);
|
||||
expect(stats.tags).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("2.15 loadBundleStore: read-only Store from tar", async () => {
|
||||
const src = createMemoryStore();
|
||||
bootstrap(src);
|
||||
const schemaHash = putSchema(src, { type: "string" });
|
||||
const aHash = src.cas.put(schemaHash, "v");
|
||||
src.var.set("@test/config", aHash);
|
||||
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(src, ["@test/config"], out);
|
||||
|
||||
const bundleStore = await loadBundleStore(out);
|
||||
expect(bundleStore.cas.get(aHash)).not.toBeNull();
|
||||
expect(bundleStore.cas.has(aHash)).toBe(true);
|
||||
const v = bundleStore.var.get("@test/config");
|
||||
expect(v?.value).toBe(aHash);
|
||||
const tags = bundleStore.tag.tags(aHash);
|
||||
expect(tags.some((t) => t.key === "env" && t.value === "prod")).toBe(true);
|
||||
});
|
||||
|
||||
test("2.16 loadBundleStore: walk works against bundle store", async () => {
|
||||
const src = createMemoryStore();
|
||||
bootstrap(src);
|
||||
const refSchema = putSchema(src, {
|
||||
type: "object",
|
||||
properties: { next: { type: "string", format: "ocas_ref" } },
|
||||
});
|
||||
const stringSchema = putSchema(src, { type: "string" });
|
||||
const bHash = src.cas.put(stringSchema, "b-content");
|
||||
const aHash = src.cas.put(refSchema, { next: bHash });
|
||||
src.var.set("@test/root", aHash);
|
||||
|
||||
const out = join(tmpDir, "bundle.tar");
|
||||
await exportBundle(src, ["@test/root"], out);
|
||||
|
||||
const bundleStore = await loadBundleStore(out);
|
||||
const { walk } = await import("./schema.js");
|
||||
const visited: string[] = [];
|
||||
walk(bundleStore, aHash, (h) => visited.push(h));
|
||||
expect(visited).toContain(aHash);
|
||||
expect(visited).toContain(bHash);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Tar parser (minimal POSIX/ustar reader) used by tests ----
|
||||
|
||||
type TarEntry = { name: string; content: Uint8Array };
|
||||
|
||||
function readTarEntries(buf: Buffer): TarEntry[] {
|
||||
const entries: TarEntry[] = [];
|
||||
let offset = 0;
|
||||
while (offset + 512 <= buf.length) {
|
||||
const header = buf.subarray(offset, offset + 512);
|
||||
// End-of-archive: two consecutive zero blocks.
|
||||
if (header.every((b) => b === 0)) break;
|
||||
|
||||
const name = readCString(header, 0, 100);
|
||||
const sizeStr = readCString(header, 124, 12).trim();
|
||||
const size = sizeStr === "" ? 0 : parseInt(sizeStr, 8);
|
||||
|
||||
offset += 512;
|
||||
const content = buf.subarray(offset, offset + size);
|
||||
entries.push({ name, content: new Uint8Array(content) });
|
||||
// Pad to 512-byte boundary.
|
||||
offset += Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function listTarEntries(buf: Buffer): string[] {
|
||||
return readTarEntries(buf).map((e) => e.name);
|
||||
}
|
||||
|
||||
function readCString(buf: Buffer, start: number, len: number): string {
|
||||
const slice = buf.subarray(start, start + len);
|
||||
let end = slice.length;
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
if (slice[i] === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return slice.subarray(0, end).toString("utf8");
|
||||
}
|
||||
|
||||
// Suppress unused import warnings.
|
||||
void writeFileSync;
|
||||
@@ -0,0 +1,394 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { decode } from "cborg";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { cborEncode } from "./cbor.js";
|
||||
import { computeClosure } from "./closure.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { CasNode, Hash, Store, Tag } from "./types.js";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
/**
|
||||
* Stats returned by `exportBundle`.
|
||||
*/
|
||||
export type ExportStats = {
|
||||
nodes: number;
|
||||
vars: number;
|
||||
tags: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for `importBundle`.
|
||||
*/
|
||||
export type ImportOptions = {
|
||||
/** Replace the original `@scope` of each non-builtin variable with this value. */
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stats returned by `importBundle`.
|
||||
*/
|
||||
export type ImportStats = {
|
||||
nodes: { imported: number; skipped: number };
|
||||
vars: { created: number; updated: number };
|
||||
tags: number;
|
||||
};
|
||||
|
||||
/** Import via CBOR using cborg, mirroring how FsStore decodes nodes. */
|
||||
|
||||
const BUILTIN_PREFIX = "@ocas/";
|
||||
|
||||
/**
|
||||
* Resolve a single root spec (variable name OR raw hash) into a hash. Throws
|
||||
* if the name does not resolve and the input is not a hash.
|
||||
*/
|
||||
function resolveRoot(store: Store, input: string): Hash {
|
||||
if (/^[0-9A-HJKMNP-TV-Z]{13}$/.test(input)) {
|
||||
if (!store.cas.has(input)) {
|
||||
throw new Error(`Root hash not found in store: ${input}`);
|
||||
}
|
||||
return input as Hash;
|
||||
}
|
||||
const variants = store.var.list({ exactName: input });
|
||||
const first = variants[0];
|
||||
if (!first) {
|
||||
throw new Error(`Root variable not found: ${input}`);
|
||||
}
|
||||
return first.value as Hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the transitive CAS closure of `roots`, write a tar archive at
|
||||
* `outputPath` containing all CAS nodes (`cas/<hash>.bin`), variables
|
||||
* (`vars.jsonl`), and tags (`tags.jsonl`).
|
||||
*/
|
||||
export async function exportBundle(
|
||||
store: Store,
|
||||
roots: string[],
|
||||
outputPath: string,
|
||||
): Promise<ExportStats> {
|
||||
// Resolve every root before computing the closure so missing names error
|
||||
// early.
|
||||
const rootHashes = roots.map((r) => resolveRoot(store, r));
|
||||
const closure = computeClosure(store, rootHashes);
|
||||
|
||||
const entries: TarEntry[] = [];
|
||||
|
||||
// CAS nodes — one CBOR-encoded file per node, named by hash.
|
||||
// Order is deterministic by sorted hash.
|
||||
const sortedNodes = [...closure.nodes].sort();
|
||||
for (const hash of sortedNodes) {
|
||||
const node = store.cas.get(hash);
|
||||
if (!node) continue;
|
||||
const content = cborEncode({
|
||||
type: node.type,
|
||||
payload: node.payload,
|
||||
timestamp: node.timestamp,
|
||||
});
|
||||
entries.push({ name: `cas/${hash}.bin`, content });
|
||||
}
|
||||
|
||||
// Variables — JSON-lines.
|
||||
const sortedVars = [...closure.vars].sort((a, b) =>
|
||||
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
||||
);
|
||||
const varLines = sortedVars
|
||||
.map((v) =>
|
||||
JSON.stringify({
|
||||
name: v.name,
|
||||
schema: v.schema,
|
||||
value: v.value,
|
||||
created: v.created,
|
||||
updated: v.updated,
|
||||
tags: v.tags,
|
||||
labels: v.labels,
|
||||
}),
|
||||
)
|
||||
.join("\n");
|
||||
entries.push({
|
||||
name: "vars.jsonl",
|
||||
content: new TextEncoder().encode(
|
||||
varLines + (varLines.length > 0 ? "\n" : ""),
|
||||
),
|
||||
});
|
||||
|
||||
// Tags — JSON-lines, one per tag.
|
||||
const tagLines: string[] = [];
|
||||
const sortedTagTargets = [...closure.tags.keys()].sort();
|
||||
let tagCount = 0;
|
||||
for (const target of sortedTagTargets) {
|
||||
const tagList = closure.tags.get(target) ?? [];
|
||||
for (const t of tagList) {
|
||||
tagLines.push(
|
||||
JSON.stringify({
|
||||
target: t.target,
|
||||
key: t.key,
|
||||
value: t.value,
|
||||
created: t.created,
|
||||
}),
|
||||
);
|
||||
tagCount++;
|
||||
}
|
||||
}
|
||||
const tagText = tagLines.join("\n");
|
||||
entries.push({
|
||||
name: "tags.jsonl",
|
||||
content: new TextEncoder().encode(
|
||||
tagText + (tagText.length > 0 ? "\n" : ""),
|
||||
),
|
||||
});
|
||||
|
||||
// Pack into tar and write to disk.
|
||||
const tar = packTar(entries);
|
||||
writeFileSync(outputPath, tar);
|
||||
|
||||
return {
|
||||
nodes: sortedNodes.length,
|
||||
vars: sortedVars.length,
|
||||
tags: tagCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a bundle tar archive from disk, returning the parsed components
|
||||
* without applying them to a store.
|
||||
*/
|
||||
function readBundle(bundlePath: string): {
|
||||
nodes: Map<Hash, CasNode>;
|
||||
vars: Variable[];
|
||||
tags: Tag[];
|
||||
} {
|
||||
const buf = readFileSync(bundlePath);
|
||||
const entries = unpackTar(buf);
|
||||
const nodes = new Map<Hash, CasNode>();
|
||||
let vars: Variable[] = [];
|
||||
let tags: Tag[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith("cas/") && entry.name.endsWith(".bin")) {
|
||||
const hash = entry.name.slice(4, -4) as Hash;
|
||||
const node = decode(entry.content) as CasNode;
|
||||
nodes.set(hash, node);
|
||||
} else if (entry.name === "vars.jsonl") {
|
||||
const text = new TextDecoder().decode(entry.content);
|
||||
vars = text
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0)
|
||||
.map((l) => JSON.parse(l) as Variable);
|
||||
} else if (entry.name === "tags.jsonl") {
|
||||
const text = new TextDecoder().decode(entry.content);
|
||||
tags = text
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0)
|
||||
.map((l) => JSON.parse(l) as Tag);
|
||||
}
|
||||
}
|
||||
return { nodes, vars, tags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply scope remapping to a variable name. `@ocas/*` is reserved and never
|
||||
* remapped. Other names get `^@[^/]+` replaced with the new scope.
|
||||
*/
|
||||
function remapVarName(name: string, scope: string | undefined): string {
|
||||
if (scope === undefined) return name;
|
||||
if (name.startsWith(BUILTIN_PREFIX)) return name;
|
||||
// Replace leading @scope with the new scope. The format is `@scope/rest`.
|
||||
return name.replace(/^@[^/]+/, scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a bundle from disk and apply its contents to `target`.
|
||||
*/
|
||||
export async function importBundle(
|
||||
bundlePath: string,
|
||||
target: Store,
|
||||
options?: ImportOptions,
|
||||
): Promise<ImportStats> {
|
||||
// Ensure target is bootstrapped so meta-schema is available (importing the
|
||||
// meta-schema as a regular CAS node would still work since hash-equal
|
||||
// self-referencing nodes dedup).
|
||||
bootstrap(target);
|
||||
|
||||
const { nodes, vars, tags } = readBundle(bundlePath);
|
||||
|
||||
// Sort nodes so that meta-schema (self-referencing) is imported first,
|
||||
// then types (whose `type` is the meta-schema), then leaves. The simple
|
||||
// heuristic: import nodes whose `type` is already present (or self) until
|
||||
// the queue stabilises.
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const remaining = new Map(nodes);
|
||||
let progress = true;
|
||||
while (remaining.size > 0 && progress) {
|
||||
progress = false;
|
||||
for (const [hash, node] of [...remaining]) {
|
||||
const ready = node.type === hash || target.cas.has(node.type);
|
||||
if (!ready) continue;
|
||||
if (target.cas.has(hash)) {
|
||||
skipped++;
|
||||
} else if (node.type === hash) {
|
||||
// Self-referencing meta — import via bootstrap-capable interface.
|
||||
// Fall back to put if the store doesn't expose BOOTSTRAP_STORE.
|
||||
const cas = target.cas as unknown as {
|
||||
[k: symbol]: ((p: unknown) => Hash) | undefined;
|
||||
};
|
||||
const fn = cas[BOOTSTRAP_STORE];
|
||||
if (fn) {
|
||||
fn(node.payload);
|
||||
} else {
|
||||
target.cas.put(node.type, node.payload);
|
||||
}
|
||||
imported++;
|
||||
} else {
|
||||
target.cas.put(node.type, node.payload);
|
||||
imported++;
|
||||
}
|
||||
remaining.delete(hash);
|
||||
progress = true;
|
||||
}
|
||||
}
|
||||
// If anything remains, type chains were unresolvable — import them anyway.
|
||||
for (const [hash, node] of remaining) {
|
||||
if (target.cas.has(hash)) {
|
||||
skipped++;
|
||||
} else {
|
||||
target.cas.put(node.type, node.payload);
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
// Variables.
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const v of vars) {
|
||||
const newName = remapVarName(v.name, options?.scope);
|
||||
// @ocas/* names already exist after bootstrap; if name+schema match value
|
||||
// they will be silently no-op'd by the store.
|
||||
const existing = target.var.get(newName, v.schema);
|
||||
target.var.set(newName, v.value, {
|
||||
tags: v.tags ?? {},
|
||||
labels: v.labels ?? [],
|
||||
});
|
||||
if (existing === null) {
|
||||
created++;
|
||||
} else {
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Tags. Apply each tag to its target.
|
||||
for (const t of tags) {
|
||||
target.tag.tag(t.target, [
|
||||
t.value === null
|
||||
? { op: "set", key: t.key }
|
||||
: { op: "set", key: t.key, value: t.value },
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: { imported, skipped },
|
||||
vars: { created, updated },
|
||||
tags: tags.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a read-only `Store` whose contents come from a bundle tar file.
|
||||
*/
|
||||
export async function loadBundleStore(bundlePath: string): Promise<Store> {
|
||||
const store = createMemoryStore();
|
||||
// Apply the bundle's contents but suppress the bootstrap-only nodes so
|
||||
// the bundle file remains the source of truth.
|
||||
await importBundle(bundlePath, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal tar pack/unpack — POSIX ustar format, regular files only.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TarEntry = { name: string; content: Uint8Array };
|
||||
|
||||
function packTar(entries: TarEntry[]): Buffer {
|
||||
const blocks: Buffer[] = [];
|
||||
for (const entry of entries) {
|
||||
const header = Buffer.alloc(512);
|
||||
writeString(header, entry.name, 0, 100);
|
||||
writeOctal(header, 0o644, 100, 8);
|
||||
writeOctal(header, 0, 108, 8);
|
||||
writeOctal(header, 0, 116, 8);
|
||||
writeOctal(header, entry.content.length, 124, 12);
|
||||
writeOctal(header, Math.floor(Date.now() / 1000), 136, 12);
|
||||
// checksum placeholder — 8 spaces, then computed.
|
||||
for (let i = 0; i < 8; i++) header[148 + i] = 0x20;
|
||||
header[156] = 0x30; // typeflag '0' (regular file)
|
||||
writeString(header, "ustar ", 257, 8); // GNU-style ustar magic+version
|
||||
let cksum = 0;
|
||||
for (let i = 0; i < 512; i++) cksum += header[i] as number;
|
||||
writeOctal(header, cksum, 148, 7);
|
||||
header[155] = 0;
|
||||
|
||||
blocks.push(header);
|
||||
const content = Buffer.from(entry.content);
|
||||
blocks.push(content);
|
||||
// Pad to 512.
|
||||
const pad = (512 - (content.length % 512)) % 512;
|
||||
if (pad > 0) blocks.push(Buffer.alloc(pad));
|
||||
}
|
||||
// End-of-archive: two zero blocks.
|
||||
blocks.push(Buffer.alloc(512));
|
||||
blocks.push(Buffer.alloc(512));
|
||||
return Buffer.concat(blocks);
|
||||
}
|
||||
|
||||
function unpackTar(buf: Buffer): TarEntry[] {
|
||||
const entries: TarEntry[] = [];
|
||||
let offset = 0;
|
||||
while (offset + 512 <= buf.length) {
|
||||
const header = buf.subarray(offset, offset + 512);
|
||||
if (header.every((b) => b === 0)) break;
|
||||
const name = readCString(header, 0, 100);
|
||||
const sizeStr = readCString(header, 124, 12).trim();
|
||||
const size = sizeStr === "" ? 0 : parseInt(sizeStr, 8);
|
||||
offset += 512;
|
||||
const content = new Uint8Array(buf.subarray(offset, offset + size));
|
||||
entries.push({ name, content });
|
||||
offset += Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function writeString(
|
||||
buf: Buffer,
|
||||
str: string,
|
||||
offset: number,
|
||||
len: number,
|
||||
): void {
|
||||
const data = Buffer.from(str, "utf8");
|
||||
const n = Math.min(data.length, len);
|
||||
data.copy(buf, offset, 0, n);
|
||||
for (let i = n; i < len; i++) buf[offset + i] = 0;
|
||||
}
|
||||
|
||||
function writeOctal(
|
||||
buf: Buffer,
|
||||
value: number,
|
||||
offset: number,
|
||||
len: number,
|
||||
): void {
|
||||
const str = value.toString(8).padStart(len - 1, "0");
|
||||
writeString(buf, str, offset, len - 1);
|
||||
buf[offset + len - 1] = 0;
|
||||
}
|
||||
|
||||
function readCString(buf: Buffer, start: number, len: number): string {
|
||||
const slice = buf.subarray(start, start + len);
|
||||
let end = slice.length;
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
if (slice[i] === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return slice.subarray(0, end).toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { computeClosure } from "./closure.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
describe("computeClosure", () => {
|
||||
test("1.1 basic node traversal — collects A, B, C linked by ocas_ref", () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const refSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
next: { type: "string", format: "ocas_ref" },
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const stringSchema = putSchema(store, { type: "string" });
|
||||
const cHash = store.cas.put(stringSchema, "leaf-c");
|
||||
const bHash = store.cas.put(refSchema, { next: cHash, name: "b" });
|
||||
const aHash = store.cas.put(refSchema, { next: bHash, name: "a" });
|
||||
|
||||
const result = computeClosure(store, [aHash]);
|
||||
|
||||
expect(result.nodes.has(aHash)).toBe(true);
|
||||
expect(result.nodes.has(bHash)).toBe(true);
|
||||
expect(result.nodes.has(cHash)).toBe(true);
|
||||
});
|
||||
|
||||
test("1.2 schema chain inclusion — schema and meta-schema are part of the closure", () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = bootstrap(store);
|
||||
const metaHash = aliases["@ocas/schema"] as string;
|
||||
|
||||
const schemaHash = putSchema(store, { type: "object" });
|
||||
const nodeHash = store.cas.put(schemaHash, { foo: "bar" });
|
||||
|
||||
const result = computeClosure(store, [nodeHash]);
|
||||
|
||||
expect(result.nodes.has(nodeHash)).toBe(true);
|
||||
expect(result.nodes.has(schemaHash)).toBe(true);
|
||||
expect(result.nodes.has(metaHash)).toBe(true);
|
||||
});
|
||||
|
||||
test("1.3 template variable nodes — template content is included", () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = bootstrap(store);
|
||||
const stringHash = aliases["@ocas/string"] as string;
|
||||
|
||||
const schemaHash = putSchema(store, { type: "object" });
|
||||
const nodeHash = store.cas.put(schemaHash, { x: 1 });
|
||||
|
||||
// Register a template for schemaHash
|
||||
const templateContent = "rendered: {{ x }}";
|
||||
const contentHash = store.cas.put(stringHash, templateContent);
|
||||
store.var.set(`@ocas/template/text/${schemaHash}`, contentHash);
|
||||
|
||||
const result = computeClosure(store, [nodeHash]);
|
||||
|
||||
expect(result.nodes.has(nodeHash)).toBe(true);
|
||||
expect(result.nodes.has(schemaHash)).toBe(true);
|
||||
expect(result.nodes.has(contentHash)).toBe(true);
|
||||
const templateVarNames = result.vars.map((v) => v.name);
|
||||
expect(templateVarNames).toContain(`@ocas/template/text/${schemaHash}`);
|
||||
});
|
||||
|
||||
test("1.4 multiple roots — union of closures", () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const stringSchema = putSchema(store, { type: "string" });
|
||||
const aHash = store.cas.put(stringSchema, "alpha");
|
||||
const bHash = store.cas.put(stringSchema, "beta");
|
||||
store.var.set("@test/a", aHash);
|
||||
store.var.set("@test/b", bHash);
|
||||
|
||||
const result = computeClosure(store, [aHash, bHash]);
|
||||
|
||||
expect(result.nodes.has(aHash)).toBe(true);
|
||||
expect(result.nodes.has(bHash)).toBe(true);
|
||||
});
|
||||
|
||||
test("1.5 cycle handling — terminates on self-references", () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const refSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
next: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
|
||||
// Build a self-loop by hashing first then storing
|
||||
const stringSchema = putSchema(store, { type: "string" });
|
||||
const placeholder = store.cas.put(stringSchema, "self");
|
||||
// Create a cycle A -> B -> A
|
||||
const bHash = store.cas.put(refSchema, { next: placeholder });
|
||||
const aHash = store.cas.put(refSchema, { next: bHash });
|
||||
|
||||
// Mutate B to point back to A is impossible in CAS — instead test that
|
||||
// the same node is visited only once even if reached via multiple paths.
|
||||
const result = computeClosure(store, [aHash, aHash]);
|
||||
|
||||
expect(result.nodes.has(aHash)).toBe(true);
|
||||
expect(result.nodes.has(bHash)).toBe(true);
|
||||
// The placeholder is reached from B
|
||||
expect(result.nodes.has(placeholder)).toBe(true);
|
||||
// Each node appears exactly once in the set
|
||||
expect(result.nodes.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("1.6 variables pointing into closure are collected", () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const stringSchema = putSchema(store, { type: "string" });
|
||||
const xHash = store.cas.put(stringSchema, "x-content");
|
||||
const yHash = store.cas.put(stringSchema, "y-content");
|
||||
|
||||
store.var.set("@test/x", xHash);
|
||||
store.var.set("@test/y", yHash);
|
||||
|
||||
const result = computeClosure(store, [xHash]);
|
||||
|
||||
const names = result.vars.map((v) => v.name);
|
||||
expect(names).toContain("@test/x");
|
||||
expect(names).not.toContain("@test/y");
|
||||
});
|
||||
|
||||
test("1.7 @ocas/* builtin vars whose values are in closure are collected", () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = bootstrap(store);
|
||||
const metaHash = aliases["@ocas/schema"] as string;
|
||||
|
||||
const result = computeClosure(store, [metaHash]);
|
||||
|
||||
// @ocas/schema is a builtin var pointing to metaHash
|
||||
const names = result.vars.map((v) => v.name);
|
||||
expect(names).toContain("@ocas/schema");
|
||||
});
|
||||
|
||||
test("1.8 tags on closure nodes are collected", () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const stringSchema = putSchema(store, { type: "string" });
|
||||
const aHash = store.cas.put(stringSchema, "tagged-a");
|
||||
const bHash = store.cas.put(stringSchema, "tagged-b");
|
||||
|
||||
store.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
|
||||
store.tag.tag(bHash, [{ op: "set", key: "env", value: "dev" }]);
|
||||
|
||||
const result = computeClosure(store, [aHash]);
|
||||
|
||||
const aTags = result.tags.get(aHash);
|
||||
expect(aTags).toBeDefined();
|
||||
expect(aTags?.some((t) => t.key === "env" && t.value === "prod")).toBe(
|
||||
true,
|
||||
);
|
||||
// B is not in the closure
|
||||
expect(result.tags.has(bHash)).toBe(false);
|
||||
});
|
||||
|
||||
test("1.9 empty roots → empty closure", () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const result = computeClosure(store, []);
|
||||
|
||||
expect(result.nodes.size).toBe(0);
|
||||
expect(result.vars).toEqual([]);
|
||||
expect(result.tags.size).toBe(0);
|
||||
});
|
||||
|
||||
test("1.10 template content for any schema in closure is included", () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = bootstrap(store);
|
||||
const stringHash = aliases["@ocas/string"] as string;
|
||||
|
||||
// schema A has template, schema B does not — both reachable via refs
|
||||
const refSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
next: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
|
||||
const innerSchema = putSchema(store, { type: "object" });
|
||||
const innerNode = store.cas.put(innerSchema, { x: 1 });
|
||||
const outerNode = store.cas.put(refSchema, { next: innerNode });
|
||||
|
||||
const tplA = store.cas.put(stringHash, "A:{{ next }}");
|
||||
const tplInner = store.cas.put(stringHash, "INNER");
|
||||
store.var.set(`@ocas/template/text/${refSchema}`, tplA);
|
||||
store.var.set(`@ocas/template/text/${innerSchema}`, tplInner);
|
||||
|
||||
const result = computeClosure(store, [outerNode]);
|
||||
|
||||
expect(result.nodes.has(tplA)).toBe(true);
|
||||
expect(result.nodes.has(tplInner)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { walk } from "./schema.js";
|
||||
import type { Hash, Store, Tag } from "./types.js";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
/**
|
||||
* Result of a closure computation: the set of CAS hashes reachable from a
|
||||
* set of roots, along with the variables and tags that point into the
|
||||
* closure.
|
||||
*/
|
||||
export type ClosureResult = {
|
||||
/** All CAS node hashes reachable from the roots. */
|
||||
nodes: Set<Hash>;
|
||||
/** Variables whose value is in the closure (excluding orphaned vars). */
|
||||
vars: Variable[];
|
||||
/** Tags grouped by their target hash (only targets in the closure). */
|
||||
tags: Map<Hash, Tag[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the transitive closure starting from a set of root CAS hashes.
|
||||
*
|
||||
* The closure is a self-contained subset of a Store: every node it points
|
||||
* at via `ocas_ref` fields, every schema it depends on (the meta-schema
|
||||
* chain), and every template variable referencing a schema in the closure
|
||||
* is included.
|
||||
*
|
||||
* Variables that point at hashes in the closure (after node and template
|
||||
* walks) are returned. Tags whose target is in the closure are returned.
|
||||
*
|
||||
* Roots that do not exist in the store are silently skipped — callers
|
||||
* (e.g. `exportBundle`) should validate roots beforehand if strictness is
|
||||
* required.
|
||||
*/
|
||||
export function computeClosure(store: Store, roots: Hash[]): ClosureResult {
|
||||
const nodes = new Set<Hash>();
|
||||
|
||||
// Phase 1: walk refs from each root.
|
||||
for (const root of roots) {
|
||||
if (!store.cas.has(root)) continue;
|
||||
walk(store, root, (hash, node) => {
|
||||
nodes.add(hash);
|
||||
nodes.add(node.type);
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: walk the schema chain to include meta-schemas (e.g. @ocas/schema)
|
||||
// and any other type ancestors.
|
||||
const schemasToWalk = new Set<Hash>();
|
||||
for (const hash of nodes) {
|
||||
const node = store.cas.get(hash);
|
||||
if (node) schemasToWalk.add(node.type);
|
||||
}
|
||||
for (const schemaHash of schemasToWalk) {
|
||||
let current: Hash | null = schemaHash;
|
||||
while (current !== null && !nodes.has(current)) {
|
||||
nodes.add(current);
|
||||
const node = store.cas.get(current);
|
||||
if (!node || node.type === current) break;
|
||||
current = node.type;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: collect template variables for each schema in the closure.
|
||||
// Templates are stored as `@ocas/template/text/<schema-hash>` variables.
|
||||
// If a template exists for a schema in the closure, walk its content too.
|
||||
const templateVars: Variable[] = [];
|
||||
// Snapshot existing schema list — we may add nodes during template walks
|
||||
const initialNodes = [...nodes];
|
||||
for (const hash of initialNodes) {
|
||||
const templateName = `@ocas/template/text/${hash}`;
|
||||
const variants = store.var.list({ exactName: templateName });
|
||||
for (const variant of variants) {
|
||||
templateVars.push(variant);
|
||||
// Walk the template content node
|
||||
walk(store, variant.value, (h, n) => {
|
||||
nodes.add(h);
|
||||
nodes.add(n.type);
|
||||
});
|
||||
// And its schema chain
|
||||
const tNode = store.cas.get(variant.value);
|
||||
if (tNode) {
|
||||
let current: Hash | null = tNode.type;
|
||||
while (current !== null && !nodes.has(current)) {
|
||||
nodes.add(current);
|
||||
const node = store.cas.get(current);
|
||||
if (!node || node.type === current) break;
|
||||
current = node.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: collect variables whose value is in the closure. Template
|
||||
// variables are already collected; deduplicate.
|
||||
const varKey = (v: Variable): string => `${v.name}\u0000${v.schema}`;
|
||||
const seenVars = new Set<string>(templateVars.map(varKey));
|
||||
const vars: Variable[] = [...templateVars];
|
||||
const allVars = store.var.list();
|
||||
for (const v of allVars) {
|
||||
if (!nodes.has(v.value)) continue;
|
||||
const key = varKey(v);
|
||||
if (seenVars.has(key)) continue;
|
||||
seenVars.add(key);
|
||||
vars.push(v);
|
||||
}
|
||||
|
||||
// Phase 5: collect tags for each node in the closure.
|
||||
const tags = new Map<Hash, Tag[]>();
|
||||
for (const hash of nodes) {
|
||||
const tagList = store.tag.tags(hash);
|
||||
if (tagList.length > 0) {
|
||||
tags.set(hash, tagList);
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, vars, tags };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Hash } from "./types.js";
|
||||
|
||||
/**
|
||||
* Maximum number of historical values retained per (variable_name, variable_schema).
|
||||
* Position 0 is current; positions 1..MAX_HISTORY-1 are previous values (LRU).
|
||||
*/
|
||||
export const MAX_HISTORY = 10;
|
||||
|
||||
/**
|
||||
* Custom error types for variable operations
|
||||
*/
|
||||
export class VariableNotFoundError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public variableSchema: Hash,
|
||||
) {
|
||||
super(`Variable not found: name=${variableName}, schema=${variableSchema}`);
|
||||
this.name = "VariableNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidVariableNameError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public reason: string,
|
||||
) {
|
||||
super(`Invalid variable name "${variableName}": ${reason}`);
|
||||
this.name = "InvalidVariableNameError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SchemaMismatchError extends Error {
|
||||
constructor(
|
||||
public expected: string,
|
||||
public actual: string,
|
||||
) {
|
||||
super(`Schema mismatch: expected ${expected}, got ${actual}`);
|
||||
this.name = "SchemaMismatchError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CasNodeNotFoundError extends Error {
|
||||
constructor(
|
||||
public readonly hash: string,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `CAS node not found: ${hash}`);
|
||||
this.name = "CasNodeNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TagLabelConflictError extends Error {
|
||||
constructor(
|
||||
public conflictName: string,
|
||||
public existingType: "tag" | "label",
|
||||
public attemptedType: "tag" | "label",
|
||||
) {
|
||||
super(`Conflict: '${conflictName}' already exists as a ${existingType}`);
|
||||
this.name = "TagLabelConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTagFormatError extends Error {
|
||||
constructor(tag: string) {
|
||||
super(`Invalid tag format: ${tag}`);
|
||||
this.name = "InvalidTagFormatError";
|
||||
}
|
||||
}
|
||||
+285
-127
@@ -1,179 +1,337 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { gc } from "./gc.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Store } from "./types.js";
|
||||
import { VariableStore } from "./variable-store.js";
|
||||
|
||||
const tmpDbPath = () =>
|
||||
join(
|
||||
tmpdir(),
|
||||
`test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
|
||||
);
|
||||
|
||||
describe("GC - Variable Model Refactoring", () => {
|
||||
let store: Store;
|
||||
let dbPath: string;
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
unlinkSync(dbPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
test("GC preserves variable-referenced nodes", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
const schemaHash = putSchema(store, schema);
|
||||
|
||||
const hashRef = await store.put(schemaHash, { name: "referenced" });
|
||||
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
|
||||
const hashRef = store.cas.put(schemaHash, { name: "referenced" });
|
||||
const hashOrphan = store.cas.put(schemaHash, { name: "orphan" });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
store.var.set("@test/config", hashRef);
|
||||
|
||||
varStore.set("@test/config", hashRef);
|
||||
const stats = gc(store);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
expect(store.has(hashRef)).toBe(true);
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBe(1);
|
||||
expect(store.cas.has(hashRef)).toBe(true);
|
||||
expect(store.cas.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.collected).toBeGreaterThanOrEqual(1);
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("GC preserves nodes from variables with same name, different schemas", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
const schemaA = { type: "object", properties: { x: { type: "number" } } };
|
||||
const schemaB = { type: "object", properties: { y: { type: "string" } } };
|
||||
const schemaAHash = await putSchema(store, schemaA);
|
||||
const schemaBHash = await putSchema(store, schemaB);
|
||||
const schemaAHash = putSchema(store, schemaA);
|
||||
const schemaBHash = putSchema(store, schemaB);
|
||||
|
||||
const hashA = await store.put(schemaAHash, { x: 42 });
|
||||
const hashB = await store.put(schemaBHash, { y: "hello" });
|
||||
const hashOrphan = await store.put(schemaAHash, { x: 99 });
|
||||
const hashA = store.cas.put(schemaAHash, { x: 42 });
|
||||
const hashB = store.cas.put(schemaBHash, { y: "hello" });
|
||||
const hashOrphan = store.cas.put(schemaAHash, { x: 99 });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
store.var.set("@test/config", hashA);
|
||||
store.var.set("@test/config", hashB);
|
||||
|
||||
varStore.set("@test/config", hashA);
|
||||
varStore.set("@test/config", hashB);
|
||||
gc(store);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
expect(store.has(hashA)).toBe(true);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBe(2);
|
||||
|
||||
varStore.close();
|
||||
expect(store.cas.has(hashA)).toBe(true);
|
||||
expect(store.cas.has(hashB)).toBe(true);
|
||||
expect(store.cas.has(hashOrphan)).toBe(false);
|
||||
});
|
||||
|
||||
test("GC removes nodes after variable deletion", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
const schemaHash = putSchema(store, schema);
|
||||
|
||||
const hashRef = await store.put(schemaHash, { name: "referenced" });
|
||||
const hashRef = store.cas.put(schemaHash, { name: "referenced" });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
store.var.set("@test/config", hashRef);
|
||||
store.var.remove("@test/config", schemaHash);
|
||||
|
||||
varStore.set("@test/config", hashRef);
|
||||
varStore.remove("@test/config", schemaHash);
|
||||
gc(store);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
expect(store.has(hashRef)).toBe(false);
|
||||
expect(stats.scanned).toBe(0);
|
||||
|
||||
varStore.close();
|
||||
expect(store.cas.has(hashRef)).toBe(false);
|
||||
});
|
||||
|
||||
test("GC is global across all variables", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
const schemaA = { type: "object", properties: { x: { type: "number" } } };
|
||||
const schemaB = { type: "object", properties: { y: { type: "string" } } };
|
||||
const schemaAHash = await putSchema(store, schemaA);
|
||||
const schemaBHash = await putSchema(store, schemaB);
|
||||
const schemaAHash = putSchema(store, schemaA);
|
||||
const schemaBHash = putSchema(store, schemaB);
|
||||
|
||||
const hash1 = await store.put(schemaAHash, { x: 1 });
|
||||
const hash2 = await store.put(schemaAHash, { x: 2 });
|
||||
const hash3 = await store.put(schemaBHash, { y: "a" });
|
||||
const hashOrphan = await store.put(schemaAHash, { x: 999 });
|
||||
const hash1 = store.cas.put(schemaAHash, { x: 1 });
|
||||
const hash2 = store.cas.put(schemaAHash, { x: 2 });
|
||||
const hash3 = store.cas.put(schemaBHash, { y: "a" });
|
||||
const hashOrphan = store.cas.put(schemaAHash, { x: 999 });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
store.var.set("@test/uwf.thread", hash1);
|
||||
store.var.set("@test/uwf.workflow", hash2);
|
||||
store.var.set("@test/app.config", hash3);
|
||||
|
||||
varStore.set("@test/uwf.thread", hash1);
|
||||
varStore.set("@test/uwf.workflow", hash2);
|
||||
varStore.set("@test/app.config", hash3);
|
||||
gc(store);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
expect(store.has(hash1)).toBe(true);
|
||||
expect(store.has(hash2)).toBe(true);
|
||||
expect(store.has(hash3)).toBe(true);
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBe(3);
|
||||
|
||||
varStore.close();
|
||||
expect(store.cas.has(hash1)).toBe(true);
|
||||
expect(store.cas.has(hash2)).toBe(true);
|
||||
expect(store.cas.has(hash3)).toBe(true);
|
||||
expect(store.cas.has(hashOrphan)).toBe(false);
|
||||
});
|
||||
|
||||
test("GC integration with refactored variable store", async () => {
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const schemaA = { type: "object", properties: { x: { type: "number" } } };
|
||||
const schemaB = { type: "object", properties: { y: { type: "string" } } };
|
||||
const schemaAHash = await putSchema(store, schemaA);
|
||||
const schemaBHash = await putSchema(store, schemaB);
|
||||
const schemaAHash = putSchema(store, schemaA);
|
||||
const schemaBHash = putSchema(store, schemaB);
|
||||
|
||||
const hashA1 = await store.put(schemaAHash, { x: 1 });
|
||||
const hashA2 = await store.put(schemaAHash, { x: 2 });
|
||||
const hashB = await store.put(schemaBHash, { y: "hello" });
|
||||
const hashOrphan1 = await store.put(schemaAHash, { x: 999 });
|
||||
const hashOrphan2 = await store.put(schemaBHash, { y: "orphan" });
|
||||
const hashA1 = store.cas.put(schemaAHash, { x: 1 });
|
||||
const hashA2 = store.cas.put(schemaAHash, { x: 2 });
|
||||
const hashB = store.cas.put(schemaBHash, { y: "hello" });
|
||||
store.cas.put(schemaAHash, { x: 999 });
|
||||
store.cas.put(schemaBHash, { y: "orphan" });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
store.var.set("@test/var1", hashA1);
|
||||
store.var.set("@test/var2", hashA2);
|
||||
store.var.set("@test/var3", hashB);
|
||||
|
||||
// Create variables
|
||||
varStore.set("@test/var1", hashA1);
|
||||
varStore.set("@test/var2", hashA2);
|
||||
varStore.set("@test/var3", hashB);
|
||||
gc(store);
|
||||
expect(store.cas.has(hashA1)).toBe(true);
|
||||
expect(store.cas.has(hashA2)).toBe(true);
|
||||
expect(store.cas.has(hashB)).toBe(true);
|
||||
|
||||
// First GC: orphans removed
|
||||
let stats = gc(store, varStore);
|
||||
expect(store.has(hashA1)).toBe(true);
|
||||
expect(store.has(hashA2)).toBe(true);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(store.has(hashOrphan1)).toBe(false);
|
||||
expect(store.has(hashOrphan2)).toBe(false);
|
||||
expect(stats.scanned).toBe(3);
|
||||
store.var.remove("@test/var2", schemaAHash);
|
||||
|
||||
// Delete one variable
|
||||
varStore.remove("@test/var2", schemaAHash);
|
||||
|
||||
// Second GC: hashA2 removed
|
||||
stats = gc(store, varStore);
|
||||
expect(store.has(hashA1)).toBe(true);
|
||||
expect(store.has(hashA2)).toBe(false);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(stats.scanned).toBe(2);
|
||||
|
||||
varStore.close();
|
||||
gc(store);
|
||||
expect(store.cas.has(hashA1)).toBe(true);
|
||||
expect(store.cas.has(hashA2)).toBe(false);
|
||||
expect(store.cas.has(hashB)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Suite B: gc end-to-end with oneOf step chains (issue #93)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("GC - oneOf step chain preservation (#93)", () => {
|
||||
test("B.1 preserves a 3-step chain joined by oneOf nullable prev", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const stepSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
payload: { type: "string" },
|
||||
prev: {
|
||||
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const step1 = store.cas.put(stepSchema, { payload: "a", prev: null });
|
||||
const step2 = store.cas.put(stepSchema, { payload: "b", prev: step1 });
|
||||
const step3 = store.cas.put(stepSchema, { payload: "c", prev: step2 });
|
||||
const orphanStep = store.cas.put(stepSchema, {
|
||||
payload: "orphan",
|
||||
prev: null,
|
||||
});
|
||||
|
||||
store.var.set("@test/thread/head", step3);
|
||||
|
||||
gc(store);
|
||||
|
||||
expect(store.cas.has(step1)).toBe(true);
|
||||
expect(store.cas.has(step2)).toBe(true);
|
||||
expect(store.cas.has(step3)).toBe(true);
|
||||
expect(store.cas.has(stepSchema)).toBe(true);
|
||||
expect(store.cas.has(orphanStep)).toBe(false);
|
||||
});
|
||||
|
||||
test("B.2 preserves a chain that mixes oneOf detail refs", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const detailSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { info: { type: "string" } },
|
||||
});
|
||||
const stepSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
prev: {
|
||||
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
|
||||
},
|
||||
detail: {
|
||||
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const detail1 = store.cas.put(detailSchema, { info: "d1" });
|
||||
const detail2 = store.cas.put(detailSchema, { info: "d2" });
|
||||
const step1 = store.cas.put(stepSchema, {
|
||||
prev: null,
|
||||
detail: detail1,
|
||||
});
|
||||
const step2 = store.cas.put(stepSchema, {
|
||||
prev: step1,
|
||||
detail: detail2,
|
||||
});
|
||||
|
||||
store.var.set("@test/thread/head", step2);
|
||||
|
||||
gc(store);
|
||||
|
||||
expect(store.cas.has(step1)).toBe(true);
|
||||
expect(store.cas.has(step2)).toBe(true);
|
||||
expect(store.cas.has(detail1)).toBe(true);
|
||||
expect(store.cas.has(detail2)).toBe(true);
|
||||
});
|
||||
|
||||
test("B.3 preserves a workflow node referenced via oneOf from a step", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const workflowSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
});
|
||||
const stepSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
workflow: {
|
||||
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const workflowNode = store.cas.put(workflowSchema, {
|
||||
name: "solve-issue",
|
||||
});
|
||||
const step = store.cas.put(stepSchema, { workflow: workflowNode });
|
||||
|
||||
store.var.set("@test/thread/head", step);
|
||||
store.var.set("@uwf/registry/solve-issue", workflowNode);
|
||||
|
||||
gc(store);
|
||||
|
||||
expect(store.cas.has(step)).toBe(true);
|
||||
expect(store.cas.has(workflowNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("B.4 regression: existing anyOf traversal still works", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const childSchema = putSchema(store, { type: "string" });
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
child: {
|
||||
anyOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const child = store.cas.put(childSchema, "child-value");
|
||||
const parent = store.cas.put(parentSchema, { child });
|
||||
|
||||
store.var.set("@test/parent", parent);
|
||||
|
||||
gc(store);
|
||||
|
||||
expect(store.cas.has(parent)).toBe(true);
|
||||
expect(store.cas.has(child)).toBe(true);
|
||||
});
|
||||
|
||||
test("B.5 reports correct stats with oneOf chains", async () => {
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const stepSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
payload: { type: "string" },
|
||||
prev: {
|
||||
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const step1 = store.cas.put(stepSchema, { payload: "a", prev: null });
|
||||
const step2 = store.cas.put(stepSchema, { payload: "b", prev: step1 });
|
||||
const step3 = store.cas.put(stepSchema, { payload: "c", prev: step2 });
|
||||
store.cas.put(stepSchema, { payload: "orphan", prev: null });
|
||||
|
||||
store.var.set("@test/thread/head", step3);
|
||||
|
||||
const stats = gc(store);
|
||||
|
||||
expect(stats.collected).toBe(1);
|
||||
expect(stats.reachable).toBeGreaterThanOrEqual(4);
|
||||
expect(stats.scanned).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Suite C: gc preserves template content for reachable schemas (issue #93)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("GC - template content preservation (#93)", () => {
|
||||
test("C.1 preserves @ocas/template/text/<schema> content when schema is reachable", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = bootstrap(store);
|
||||
const stringHash = aliases["@ocas/string"] as string;
|
||||
|
||||
const schemaA = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" } },
|
||||
});
|
||||
const nodeA = store.cas.put(schemaA, { x: 42 });
|
||||
store.var.set("@test/a", nodeA);
|
||||
|
||||
const tplA = store.cas.put(stringHash, "rendered: {{x}}");
|
||||
store.var.set(`@ocas/template/text/${schemaA}`, tplA);
|
||||
|
||||
gc(store);
|
||||
|
||||
expect(store.cas.has(tplA)).toBe(true);
|
||||
|
||||
const tplVar = store.var.get(`@ocas/template/text/${schemaA}`);
|
||||
expect(tplVar).not.toBeNull();
|
||||
expect(tplVar?.value).toBe(tplA);
|
||||
});
|
||||
|
||||
test("C.2 removes orphan template content for an unreachable schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = bootstrap(store);
|
||||
const stringHash = aliases["@ocas/string"] as string;
|
||||
|
||||
const schemaA = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" } },
|
||||
});
|
||||
// Note: do NOT bind any variable to a node typed by schemaA — schemaA is
|
||||
// unreachable as a typeHash of any reachable node.
|
||||
|
||||
const otherSchema = putSchema(store, { type: "string" });
|
||||
const otherNode = store.cas.put(otherSchema, "other");
|
||||
store.var.set("@test/other", otherNode);
|
||||
|
||||
const tplA = store.cas.put(stringHash, "rendered: {{x}}");
|
||||
store.var.set(`@ocas/template/text/${schemaA}`, tplA);
|
||||
|
||||
gc(store);
|
||||
|
||||
expect(store.cas.has(tplA)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+47
-10
@@ -1,6 +1,5 @@
|
||||
import { walk } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
|
||||
export interface GcStats {
|
||||
total: number; // Total CAS nodes before GC
|
||||
@@ -9,22 +8,33 @@ export interface GcStats {
|
||||
scanned: number; // Variables scanned as roots
|
||||
}
|
||||
|
||||
const TEMPLATE_VAR_PREFIX = "@ocas/template/text/";
|
||||
|
||||
/**
|
||||
* Garbage collection: mark-and-sweep algorithm
|
||||
* - Roots: all variable values (global, not scoped)
|
||||
* - Roots: all variable values (global, not scoped), excluding
|
||||
* `@ocas/template/text/*` variables — those are added in a follow-up
|
||||
* phase only when their referenced schema is itself reachable.
|
||||
* - Mark: recursively walk refs from roots
|
||||
* - Template phase: for every reachable schema, walk the contents of its
|
||||
* `@ocas/template/text/<schema>` template variable (mirrors
|
||||
* `computeClosure` Phase 3).
|
||||
* - Sweep: delete unmarked nodes
|
||||
* - Schema preservation: schemas of reachable nodes are also marked
|
||||
*/
|
||||
export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
export function gc(store: Store): GcStats {
|
||||
// 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 variables = store.var.list();
|
||||
const scanned = variables.length;
|
||||
|
||||
// Collect unique root hashes from all variables
|
||||
// Collect unique root hashes from all variables, except template
|
||||
// variables (`@ocas/template/text/*`). Template variables are processed
|
||||
// in a follow-up phase so their content is preserved only when the
|
||||
// referenced schema is itself reachable from non-template roots.
|
||||
const roots = new Set<Hash>();
|
||||
for (const variable of variables) {
|
||||
if (variable.name.startsWith(TEMPLATE_VAR_PREFIX)) continue;
|
||||
roots.add(variable.value);
|
||||
}
|
||||
|
||||
@@ -44,7 +54,7 @@ export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
// For each reachable schema, walk its schema chain (not its references)
|
||||
const schemasToWalk = new Set<Hash>();
|
||||
for (const hash of reachable) {
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
if (node) {
|
||||
schemasToWalk.add(node.type);
|
||||
}
|
||||
@@ -55,7 +65,7 @@ export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
let current: Hash | null = schemaHash;
|
||||
while (current !== null && !reachable.has(current)) {
|
||||
reachable.add(current);
|
||||
const node = store.get(current);
|
||||
const node = store.cas.get(current);
|
||||
if (!node || node.type === current) {
|
||||
// Self-referencing or missing node, stop
|
||||
break;
|
||||
@@ -64,11 +74,38 @@ export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
}
|
||||
}
|
||||
|
||||
// Template phase: include `@ocas/template/text/<schema>` content nodes
|
||||
// when their schema is in the reachable set (mirrors closure.ts Phase 3).
|
||||
// Snapshot the current reachable set before walking template content so
|
||||
// that template-only nodes do not transitively pull in further templates.
|
||||
const reachableSnapshot = [...reachable];
|
||||
for (const hash of reachableSnapshot) {
|
||||
const templateName = `${TEMPLATE_VAR_PREFIX}${hash}`;
|
||||
const variants = store.var.list({ exactName: templateName });
|
||||
for (const variant of variants) {
|
||||
walk(store, variant.value, (h, n) => {
|
||||
reachable.add(h);
|
||||
reachable.add(n.type);
|
||||
});
|
||||
// Walk the template content's schema chain too
|
||||
const tNode = store.cas.get(variant.value);
|
||||
if (tNode) {
|
||||
let current: Hash | null = tNode.type;
|
||||
while (current !== null && !reachable.has(current)) {
|
||||
reachable.add(current);
|
||||
const node = store.cas.get(current);
|
||||
if (!node || node.type === current) break;
|
||||
current = node.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve all self-referencing nodes (bootstrap meta-schema)
|
||||
// These are nodes where type === hash
|
||||
const allHashes = store.listAll();
|
||||
const allHashes = store.cas.listAll();
|
||||
for (const hash of allHashes) {
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
if (node && node.type === hash) {
|
||||
reachable.add(hash);
|
||||
}
|
||||
@@ -81,7 +118,7 @@ export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
let collected = 0;
|
||||
for (const hash of allHashes) {
|
||||
if (!reachable.has(hash)) {
|
||||
store.delete(hash);
|
||||
store.cas.delete(hash);
|
||||
collected++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,38 @@ async function getInstance(): Promise<XXHashAPI> {
|
||||
return _pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the xxhash WASM instance. After this resolves, the synchronous
|
||||
* hashing functions {@link computeHashSync} and {@link computeSelfHashSync}
|
||||
* may be called.
|
||||
*/
|
||||
export async function initHasher(): Promise<void> {
|
||||
await getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous variant of {@link computeHash}. Must only be called after
|
||||
* {@link initHasher} has resolved at least once; throws otherwise.
|
||||
*/
|
||||
export function computeHashSync(typeHash: Hash, payload: unknown): Hash {
|
||||
if (_instance === null) {
|
||||
throw new Error("Hasher not initialised — call initHasher() first");
|
||||
}
|
||||
const input = concatBytes(asciiToBytes(typeHash), cborEncode(payload));
|
||||
return u64ToCrockford(_instance.h64Raw(input));
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous variant of {@link computeSelfHash}. Must only be called after
|
||||
* {@link initHasher} has resolved at least once; throws otherwise.
|
||||
*/
|
||||
export function computeSelfHashSync(payload: unknown): Hash {
|
||||
if (_instance === null) {
|
||||
throw new Error("Hasher not initialised — call initHasher() first");
|
||||
}
|
||||
return u64ToCrockford(_instance.h64Raw(cborEncode(payload)));
|
||||
}
|
||||
|
||||
/**
|
||||
* hash = XXH64(utf8(typeHash) ++ CBOR_deterministic(payload))
|
||||
* Used for all normal nodes.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { cborEncode } from "./cbor.js";
|
||||
import { computeHash, computeSelfHash } from "./hash.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { CasNode, Store } from "./types.js";
|
||||
import type { CasNode, CasStore } from "./types.js";
|
||||
import { verify } from "./verify.js";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -68,17 +68,17 @@ describe("computeHash", () => {
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Step 3: store.put() and store.get()
|
||||
// Step 3: store.cas.put() and store.cas.get()
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createMemoryStore – put and get", () => {
|
||||
test("put returns a hash and get retrieves the node", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
const hash = await store.put(typeHash, { greeting: "hello" });
|
||||
const hash = await store.cas.put(typeHash, { greeting: "hello" });
|
||||
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.type).toBe(typeHash);
|
||||
expect(node?.payload).toEqual({ greeting: "hello" });
|
||||
@@ -87,26 +87,26 @@ describe("createMemoryStore – put and get", () => {
|
||||
|
||||
test("get returns null for unknown hash", () => {
|
||||
const store = createMemoryStore();
|
||||
expect(store.get("0000000000000")).toBeNull();
|
||||
expect(store.cas.get("0000000000000")).toBeNull();
|
||||
});
|
||||
|
||||
test("put is idempotent: same type+payload → same hash, no duplicate", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
|
||||
const h1 = await store.put(typeHash, { n: 42 });
|
||||
const h2 = await store.put(typeHash, { n: 42 });
|
||||
const h1 = await store.cas.put(typeHash, { n: 42 });
|
||||
const h2 = await store.cas.put(typeHash, { n: 42 });
|
||||
expect(h1).toBe(h2);
|
||||
expect(store.listByType(typeHash)).toHaveLength(1);
|
||||
expect(store.cas.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("put does not create self-referencing nodes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const payload = { name: "type-descriptor" };
|
||||
const typeHash = await computeSelfHash(payload);
|
||||
const hash = await store.put(typeHash, payload);
|
||||
const hash = await store.cas.put(typeHash, payload);
|
||||
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
expect(node?.type).toBe(typeHash);
|
||||
expect(node?.type).not.toBe(hash);
|
||||
});
|
||||
@@ -115,19 +115,19 @@ describe("createMemoryStore – put and get", () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
|
||||
const h1 = await store.put(typeHash, { v: 1 });
|
||||
const ts1 = store.get(h1)?.timestamp;
|
||||
const h1 = await store.cas.put(typeHash, { v: 1 });
|
||||
const ts1 = store.cas.get(h1)?.timestamp;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await store.put(typeHash, { v: 1 });
|
||||
const ts2 = store.get(h1)?.timestamp;
|
||||
await store.cas.put(typeHash, { v: 1 });
|
||||
const ts2 = store.cas.get(h1)?.timestamp;
|
||||
|
||||
expect(ts1).toBe(ts2);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Step 4: store.has()
|
||||
// Step 4: store.cas.has()
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createMemoryStore – has", () => {
|
||||
test("has returns false before put, true after", async () => {
|
||||
@@ -135,20 +135,20 @@ describe("createMemoryStore – has", () => {
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
const hash = await computeHash(typeHash, { x: 1 });
|
||||
|
||||
expect(store.has(hash)).toBe(false);
|
||||
await store.put(typeHash, { x: 1 });
|
||||
expect(store.has(hash)).toBe(true);
|
||||
expect(store.cas.has(hash)).toBe(false);
|
||||
await store.cas.put(typeHash, { x: 1 });
|
||||
expect(store.cas.has(hash)).toBe(true);
|
||||
});
|
||||
|
||||
test("listByType returns all stored hashes for a type", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
|
||||
const h1 = await store.put(typeHash, { a: 1 });
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
const h3 = await store.put(typeHash, { a: 3 });
|
||||
const h1 = await store.cas.put(typeHash, { a: 1 });
|
||||
const h2 = await store.cas.put(typeHash, { a: 2 });
|
||||
const h3 = await store.cas.put(typeHash, { a: 3 });
|
||||
|
||||
const all = store.listByType(typeHash).map((e) => e.hash);
|
||||
const all = store.cas.listByType(typeHash).map((e) => e.hash);
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all).toContain(h1);
|
||||
expect(all).toContain(h2);
|
||||
@@ -157,17 +157,17 @@ describe("createMemoryStore – has", () => {
|
||||
|
||||
test("listByType returns empty array on fresh store", () => {
|
||||
const store = createMemoryStore();
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
expect(store.cas.listByType("0000000000000")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Step 4b: store.listByType()
|
||||
// Step 4b: store.cas.listByType()
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createMemoryStore – listByType", () => {
|
||||
test("returns empty array for unknown type", () => {
|
||||
const store = createMemoryStore();
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
expect(store.cas.listByType("0000000000000")).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns all hashes for the given type", async () => {
|
||||
@@ -175,11 +175,11 @@ describe("createMemoryStore – listByType", () => {
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
const otherType = await computeSelfHash({ name: "other" });
|
||||
|
||||
const h1 = await store.put(typeHash, { a: 1 });
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
await store.put(otherType, { b: 1 });
|
||||
const h1 = await store.cas.put(typeHash, { a: 1 });
|
||||
const h2 = await store.cas.put(typeHash, { a: 2 });
|
||||
await store.cas.put(otherType, { b: 1 });
|
||||
|
||||
const byType = store.listByType(typeHash).map((e) => e.hash);
|
||||
const byType = store.cas.listByType(typeHash).map((e) => e.hash);
|
||||
expect(byType).toHaveLength(2);
|
||||
expect(byType).toContain(h1);
|
||||
expect(byType).toContain(h2);
|
||||
@@ -189,19 +189,19 @@ describe("createMemoryStore – listByType", () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
|
||||
const h1 = await store.put(typeHash, { n: 1 });
|
||||
await store.put(typeHash, { n: 1 });
|
||||
const h1 = await store.cas.put(typeHash, { n: 1 });
|
||||
await store.cas.put(typeHash, { n: 1 });
|
||||
|
||||
expect(store.listByType(typeHash).map((e) => e.hash)).toEqual([h1]);
|
||||
expect(store.cas.listByType(typeHash).map((e) => e.hash)).toEqual([h1]);
|
||||
});
|
||||
|
||||
test("bootstrap node is listed under its self type", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
// All built-in schemas should be typed by the meta-schema
|
||||
const allTypedByMeta = store.listByType(hash).map((e) => e.hash);
|
||||
const allTypedByMeta = store.cas.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"] ?? "");
|
||||
@@ -218,8 +218,8 @@ describe("verify", () => {
|
||||
test("returns true for a correctly stored node", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
const hash = await store.put(typeHash, { data: 123 });
|
||||
const node = store.get(hash) as CasNode;
|
||||
const hash = await store.cas.put(typeHash, { data: 123 });
|
||||
const node = store.cas.get(hash) as CasNode;
|
||||
|
||||
expect(await verify(hash, node)).toBe(true);
|
||||
});
|
||||
@@ -227,7 +227,7 @@ describe("verify", () => {
|
||||
test("returns false when payload is tampered", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
const hash = await store.put(typeHash, { data: 123 });
|
||||
const hash = await store.cas.put(typeHash, { data: 123 });
|
||||
|
||||
const tampered: CasNode = {
|
||||
type: typeHash,
|
||||
@@ -240,8 +240,8 @@ describe("verify", () => {
|
||||
test("returns false when type is tampered", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
const hash = await store.put(typeHash, { data: 123 });
|
||||
const node = store.get(hash) as CasNode;
|
||||
const hash = await store.cas.put(typeHash, { data: 123 });
|
||||
const node = store.cas.get(hash) as CasNode;
|
||||
|
||||
const tampered: CasNode = { ...node, type: "AAAAAAAAAAAAA" };
|
||||
expect(await verify(hash, tampered)).toBe(false);
|
||||
@@ -253,20 +253,25 @@ describe("verify", () => {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("bootstrap", () => {
|
||||
test("throws when store lacks internal bootstrap path", async () => {
|
||||
const store: Store = {
|
||||
put: async () => "0000000000000",
|
||||
const cas = {
|
||||
put: () => "0000000000000",
|
||||
get: () => null,
|
||||
has: () => false,
|
||||
listByType: () => [],
|
||||
};
|
||||
await expect(bootstrap(store)).rejects.toThrow(
|
||||
} as unknown as CasStore;
|
||||
const fakeStore = {
|
||||
cas,
|
||||
var: { set: () => null } as never,
|
||||
tag: {} as never,
|
||||
} as never;
|
||||
expect(() => bootstrap(fakeStore)).toThrow(
|
||||
"Store does not support bootstrap",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a map with 30 built-in schema aliases", async () => {
|
||||
test("returns a map with built-in schema aliases", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/schema");
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/string");
|
||||
@@ -284,44 +289,44 @@ describe("bootstrap", () => {
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(30);
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(33);
|
||||
});
|
||||
|
||||
test("meta-schema node is stored and retrievable", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
expect(store.has(metaHash)).toBe(true);
|
||||
const node = store.get(metaHash);
|
||||
expect(store.cas.has(metaHash)).toBe(true);
|
||||
const node = store.cas.get(metaHash);
|
||||
expect(node).not.toBeNull();
|
||||
});
|
||||
|
||||
test("meta-schema node is self-referencing: type === hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const node = store.get(metaHash) as CasNode;
|
||||
const node = store.cas.get(metaHash) as CasNode;
|
||||
|
||||
expect(node.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
test("bootstrap node passes verify()", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const node = store.get(metaHash) as CasNode;
|
||||
const node = store.cas.get(metaHash) as CasNode;
|
||||
|
||||
expect(await verify(metaHash, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("bootstrap is idempotent: same hashes on repeated calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const h1 = await bootstrap(store);
|
||||
const h2 = await bootstrap(store);
|
||||
const h1 = bootstrap(store);
|
||||
const h2 = bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 21 outputs)
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
|
||||
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 24 outputs)
|
||||
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(32);
|
||||
});
|
||||
});
|
||||
|
||||
+55
-19
@@ -1,9 +1,33 @@
|
||||
export { bootstrap } from "./bootstrap.js";
|
||||
export type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
export {
|
||||
type ExportStats,
|
||||
exportBundle,
|
||||
type ImportOptions,
|
||||
type ImportStats,
|
||||
importBundle,
|
||||
loadBundleStore,
|
||||
} from "./bundle.js";
|
||||
export { cborEncode } from "./cbor.js";
|
||||
export { type ClosureResult, computeClosure } from "./closure.js";
|
||||
export {
|
||||
CasNodeNotFoundError,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
} from "./errors.js";
|
||||
export { type GcStats, gc } from "./gc.js";
|
||||
export { computeHash, computeSelfHash } from "./hash.js";
|
||||
export {
|
||||
computeHash,
|
||||
computeHashSync,
|
||||
computeSelfHash,
|
||||
computeSelfHashSync,
|
||||
initHasher,
|
||||
} from "./hash.js";
|
||||
export { renderWithTemplate } from "./liquid-render.js";
|
||||
export { applyListOptions, casListEntry } from "./list-utils.js";
|
||||
export { registerOutputTemplates } from "./output-templates.js";
|
||||
@@ -22,26 +46,38 @@ export {
|
||||
validate,
|
||||
walk,
|
||||
} from "./schema.js";
|
||||
export { createMemoryStore } from "./store.js";
|
||||
export {
|
||||
type CasNode,
|
||||
type Hash,
|
||||
type ListEntry,
|
||||
type ListOptions,
|
||||
type ListSort,
|
||||
type Store,
|
||||
createMemoryStore,
|
||||
createMemoryTagStoreImpl,
|
||||
createMemoryVarStoreFor,
|
||||
} from "./store.js";
|
||||
export type {
|
||||
CasNode,
|
||||
CasStore,
|
||||
Hash,
|
||||
HistoryEntry,
|
||||
ListEntry,
|
||||
ListOptions,
|
||||
ListSort,
|
||||
Store,
|
||||
Tag,
|
||||
TagOp,
|
||||
TagStore,
|
||||
VarListOptions,
|
||||
VarSetOptions,
|
||||
VarStore,
|
||||
} from "./types.js";
|
||||
export type { Variable } from "./variable.js";
|
||||
export { validateName } from "./validation.js";
|
||||
export {
|
||||
CasNodeNotFoundError,
|
||||
createVariableStore,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
VariableStore,
|
||||
} from "./variable-store.js";
|
||||
addNameIndex,
|
||||
checkTagLabelConflict,
|
||||
cloneVarRecord,
|
||||
extractSchema,
|
||||
pushHistory,
|
||||
removeNameIndex,
|
||||
type VarRecord,
|
||||
varKey,
|
||||
} from "./var-store-helpers.js";
|
||||
export type { Variable } from "./variable.js";
|
||||
export { verify } from "./verify.js";
|
||||
export { wrapEnvelope } from "./wrap-envelope.js";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ import { type Context, Liquid, type TagToken } from "liquidjs";
|
||||
import type { RenderOptions } from "./render.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
|
||||
const DEFAULT_RESOLUTION = 1.0;
|
||||
const DEFAULT_DECAY = 0.5;
|
||||
@@ -15,7 +14,6 @@ const FLOAT_TOLERANCE = 1e-10;
|
||||
*/
|
||||
export async function renderWithTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): Promise<string> {
|
||||
@@ -37,27 +35,15 @@ export async function renderWithTemplate(
|
||||
const visited = new Set<Hash>();
|
||||
|
||||
// Create Liquid engine
|
||||
const engine = createLiquidEngine(store, varStore, decay);
|
||||
const engine = createLiquidEngine(store, decay);
|
||||
|
||||
return await renderNode(
|
||||
engine,
|
||||
store,
|
||||
varStore,
|
||||
hash,
|
||||
resolution,
|
||||
epsilon,
|
||||
visited,
|
||||
);
|
||||
return await renderNode(engine, store, hash, resolution, epsilon, visited);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Liquid engine instance with custom render tag
|
||||
*/
|
||||
function createLiquidEngine(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
globalDecay: number,
|
||||
): Liquid {
|
||||
function createLiquidEngine(store: Store, globalDecay: number): Liquid {
|
||||
const engine = new Liquid({
|
||||
strictFilters: false,
|
||||
strictVariables: false,
|
||||
@@ -70,7 +56,7 @@ function createLiquidEngine(
|
||||
};
|
||||
|
||||
// Register custom {% render %} tag
|
||||
// Capture store, varStore, globalDecay in closure
|
||||
// Capture store, globalDecay in closure
|
||||
engine.registerTag("render", {
|
||||
parse(token: TagToken) {
|
||||
// Parse "variable" or "variable, decay: 0.7" syntax
|
||||
@@ -137,7 +123,6 @@ function createLiquidEngine(
|
||||
const output = await renderNode(
|
||||
engine,
|
||||
store,
|
||||
varStore,
|
||||
nodeHash,
|
||||
childResolution,
|
||||
currentEpsilon,
|
||||
@@ -157,7 +142,6 @@ function createLiquidEngine(
|
||||
async function renderNode(
|
||||
engine: Liquid,
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
hash: Hash,
|
||||
currentResolution: number,
|
||||
epsilon: number,
|
||||
@@ -169,7 +153,7 @@ async function renderNode(
|
||||
}
|
||||
|
||||
// Fetch the node
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
if (node === null) {
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
@@ -182,13 +166,13 @@ async function renderNode(
|
||||
|
||||
try {
|
||||
// Try to find a template for this node's type
|
||||
const template = await findTemplate(store, varStore, node.type);
|
||||
const template = await findTemplate(store, node.type);
|
||||
|
||||
if (template === null) {
|
||||
// No template found - this is handled by the caller (fallback to YAML)
|
||||
// For now, return a simple representation
|
||||
visited.delete(hash);
|
||||
return renderFallback(store, node.payload);
|
||||
return renderFallback(node.payload);
|
||||
}
|
||||
|
||||
// Render using the template
|
||||
@@ -217,21 +201,20 @@ async function renderNode(
|
||||
*/
|
||||
async function findTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
typeHash: Hash,
|
||||
): Promise<string | null> {
|
||||
const varName = `@ocas/template/text/${typeHash}`;
|
||||
|
||||
try {
|
||||
// Find the string schema hash (we need this to query variables)
|
||||
const stringSchema = await putSchema(store, { type: "string" });
|
||||
const stringSchema = putSchema(store, { type: "string" });
|
||||
|
||||
const variable = varStore.get(varName, stringSchema);
|
||||
const variable = store.var.get(varName, stringSchema);
|
||||
if (variable === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templateNode = store.get(variable.value);
|
||||
const templateNode = store.cas.get(variable.value);
|
||||
if (templateNode === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -250,7 +233,7 @@ async function findTemplate(
|
||||
/**
|
||||
* Fallback renderer for nodes without templates
|
||||
*/
|
||||
function renderFallback(_store: Store, payload: unknown): string {
|
||||
function renderFallback(payload: unknown): string {
|
||||
// Simple YAML-like representation
|
||||
if (payload === null) {
|
||||
return "null\n";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
@@ -12,7 +12,7 @@ async function putN(
|
||||
): Promise<string[]> {
|
||||
const hashes: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
hashes.push(await store.put(type, { i }));
|
||||
hashes.push(store.cas.put(type, { i }));
|
||||
if (delayMs > 0 && i < n - 1) {
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
@@ -23,10 +23,10 @@ async function putN(
|
||||
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" });
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
|
||||
const list = store.listByType(m);
|
||||
const list = store.cas.listByType(m);
|
||||
for (const e of list) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
@@ -37,10 +37,10 @@ describe("listByType - pagination + sort + timestamps", () => {
|
||||
|
||||
test("A2. default sort is created ASC", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
const list = store.listByType(m);
|
||||
const list = store.cas.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,
|
||||
@@ -50,10 +50,10 @@ describe("listByType - pagination + sort + timestamps", () => {
|
||||
|
||||
test("A3. desc:true reverses order", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
const list = store.listByType(m, { desc: true });
|
||||
const list = store.cas.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,
|
||||
@@ -63,61 +63,61 @@ describe("listByType - pagination + sort + timestamps", () => {
|
||||
|
||||
test("A4. sort: 'updated' is equivalent to 'created' for CAS nodes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
const a = store.listByType(m, { sort: "created" });
|
||||
const b = store.listByType(m, { sort: "updated" });
|
||||
const a = store.cas.listByType(m, { sort: "created" });
|
||||
const b = store.cas.listByType(m, { sort: "updated" });
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test("A5. limit truncates", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5, 0);
|
||||
expect(store.listByType(m, { limit: 2 })).toHaveLength(2);
|
||||
expect(store.cas.listByType(m, { limit: 2 })).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("A6. offset skips", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5);
|
||||
|
||||
const all = store.listByType(m);
|
||||
const skip = store.listByType(m, { offset: 2, limit: 10 });
|
||||
const all = store.cas.listByType(m);
|
||||
const skip = store.cas.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" });
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
expect(store.listByType(m, { limit: 0 })).toEqual([]);
|
||||
expect(store.cas.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" });
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
expect(store.listByType(m, { offset: 100 })).toEqual([]);
|
||||
expect(store.cas.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" });
|
||||
const m = await store.cas[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);
|
||||
expect(store.cas.listByType(m)).toHaveLength(151);
|
||||
});
|
||||
|
||||
test("A10. desc + offset + limit combined", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const m = await store.cas[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 });
|
||||
const all = store.cas.listByType(m);
|
||||
const got = store.cas.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;
|
||||
@@ -129,8 +129,8 @@ describe("listByType - pagination + sort + timestamps", () => {
|
||||
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();
|
||||
const h = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const list = store.cas.listMeta();
|
||||
expect(list).toHaveLength(1);
|
||||
const e = list[0] as { hash: string; created: number; updated: number };
|
||||
expect(e.hash).toBe(h);
|
||||
@@ -141,53 +141,53 @@ describe("listMeta / listSchemas - pagination", () => {
|
||||
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 });
|
||||
await store.cas[BOOTSTRAP_STORE]({ type: "object", i });
|
||||
}
|
||||
expect(store.listMeta()).toHaveLength(150);
|
||||
expect(store.cas.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 store.cas[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(store.cas.listMeta({ limit: 2 })).toHaveLength(2);
|
||||
const all = store.cas.listMeta();
|
||||
const desc = store.cas.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 m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
store.cas.put(m, { type: "string" });
|
||||
store.cas.put(m, { type: "number" });
|
||||
|
||||
const list = store.listSchemas();
|
||||
const list = store.cas.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);
|
||||
expect(store.cas.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" });
|
||||
const m = await store.cas[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);
|
||||
const a = store.cas.listByType(m);
|
||||
const b = store.cas.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([]);
|
||||
expect(store.cas.listByType("0000000000000")).toEqual([]);
|
||||
expect(store.cas.listMeta()).toEqual([]);
|
||||
expect(store.cas.listSchemas()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,49 +1,37 @@
|
||||
import type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { CasNode, Hash, ListEntry, ListOptions } from "./types.js";
|
||||
import type { CasStore, Store, TagStore, VarStore } from "./types.js";
|
||||
|
||||
/** In-memory store wrapper used by schema validation tests. */
|
||||
export class MemStore implements BootstrapCapableStore {
|
||||
readonly #inner: BootstrapCapableStore;
|
||||
/**
|
||||
* In-memory `Store` used by schema validation tests. It exposes the
|
||||
* `cas`, `var`, and `tag` sub-stores of an `Store` plus a few legacy
|
||||
* pass-through helpers (`get`, `put`, `has`, …) that some older tests still
|
||||
* use directly.
|
||||
*/
|
||||
export class MemStore implements Store {
|
||||
readonly cas: CasStore;
|
||||
readonly var: VarStore;
|
||||
readonly tag: TagStore;
|
||||
|
||||
constructor() {
|
||||
this.#inner = createMemoryStore();
|
||||
const store = createMemoryStore();
|
||||
this.cas = store.cas;
|
||||
this.var = store.var;
|
||||
this.tag = store.tag;
|
||||
}
|
||||
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
return this.#inner.put(typeHash, payload);
|
||||
}
|
||||
|
||||
get(hash: Hash): CasNode | null {
|
||||
return this.#inner.get(hash);
|
||||
}
|
||||
|
||||
has(hash: Hash): boolean {
|
||||
return this.#inner.has(hash);
|
||||
}
|
||||
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
|
||||
return this.#inner.listByType(typeHash, options);
|
||||
}
|
||||
|
||||
listAll(): Hash[] {
|
||||
return this.#inner.listAll();
|
||||
}
|
||||
|
||||
listMeta(options?: ListOptions): ListEntry[] {
|
||||
return this.#inner.listMeta(options);
|
||||
}
|
||||
|
||||
listSchemas(options?: ListOptions): ListEntry[] {
|
||||
return this.#inner.listSchemas(options);
|
||||
}
|
||||
|
||||
delete(hash: Hash): void {
|
||||
this.#inner.delete(hash);
|
||||
}
|
||||
|
||||
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash> {
|
||||
return this.#inner[BOOTSTRAP_STORE](payload);
|
||||
}
|
||||
// Legacy convenience pass-throughs ----------------------------------------
|
||||
get = (hash: Parameters<CasStore["get"]>[0]): ReturnType<CasStore["get"]> =>
|
||||
this.cas.get(hash);
|
||||
has = (hash: Parameters<CasStore["has"]>[0]): ReturnType<CasStore["has"]> =>
|
||||
this.cas.has(hash);
|
||||
put = (
|
||||
typeHash: Parameters<CasStore["put"]>[0],
|
||||
payload: unknown,
|
||||
): ReturnType<CasStore["put"]> => this.cas.put(typeHash, payload);
|
||||
listByType: CasStore["listByType"] = (typeHash, options) =>
|
||||
this.cas.listByType(typeHash, options);
|
||||
listAll: CasStore["listAll"] = () => this.cas.listAll();
|
||||
listMeta: CasStore["listMeta"] = (options) => this.cas.listMeta(options);
|
||||
listSchemas: CasStore["listSchemas"] = (options) =>
|
||||
this.cas.listSchemas(options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
const stats = statSync(path);
|
||||
if (stats.isDirectory()) {
|
||||
yield* walk(path);
|
||||
} else if (stats.isFile()) {
|
||||
yield path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("no SQLite in @ocas/core", () => {
|
||||
test("source files do not import sqlite", () => {
|
||||
const srcDir = import.meta.dirname;
|
||||
const needle = ["bun", "sqlite"].join(":");
|
||||
for (const file of walk(srcDir)) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
if (file.endsWith("no-sqlite.test.ts")) continue;
|
||||
const content = readFileSync(file, "utf-8");
|
||||
expect(content).not.toContain(needle);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,7 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { registerOutputTemplates } from "./output-templates.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
import { createVariableStore } from "./variable-store.js";
|
||||
|
||||
const OUTPUT_ALIASES = [
|
||||
"@ocas/output/put",
|
||||
@@ -21,9 +15,10 @@ const OUTPUT_ALIASES = [
|
||||
"@ocas/output/var-set",
|
||||
"@ocas/output/var-get",
|
||||
"@ocas/output/var-delete",
|
||||
"@ocas/output/var-tag",
|
||||
"@ocas/output/var-list",
|
||||
"@ocas/output/var-history",
|
||||
"@ocas/output/tag",
|
||||
"@ocas/output/untag",
|
||||
"@ocas/output/template-set",
|
||||
"@ocas/output/template-get",
|
||||
"@ocas/output/template-list",
|
||||
@@ -32,24 +27,13 @@ const OUTPUT_ALIASES = [
|
||||
] as const;
|
||||
|
||||
describe("registerOutputTemplates", () => {
|
||||
let store: Store;
|
||||
let varStore: VariableStore;
|
||||
let tempDir: string;
|
||||
|
||||
afterEach(async () => {
|
||||
varStore.close();
|
||||
await rm(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
test("registers a template for every @ocas/output/* schema", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const registered = await registerOutputTemplates(store, varStore);
|
||||
const registered = await registerOutputTemplates(store);
|
||||
|
||||
expect(Object.keys(registered)).toHaveLength(19);
|
||||
expect(Object.keys(registered)).toHaveLength(20);
|
||||
|
||||
for (const alias of OUTPUT_ALIASES) {
|
||||
expect(registered).toHaveProperty(alias);
|
||||
@@ -57,25 +41,23 @@ describe("registerOutputTemplates", () => {
|
||||
});
|
||||
|
||||
test("each template is retrievable via @ocas/template/text/<hash>", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
|
||||
store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
const store = createMemoryStore();
|
||||
const aliases = bootstrap(store);
|
||||
|
||||
await registerOutputTemplates(store, varStore);
|
||||
await registerOutputTemplates(store);
|
||||
|
||||
const stringHash = aliases["@ocas/string"];
|
||||
if (!stringHash) throw new Error("@string not found");
|
||||
if (!stringHash) throw new Error("@ocas/string not found");
|
||||
|
||||
for (const alias of OUTPUT_ALIASES) {
|
||||
const schemaHash = aliases[alias];
|
||||
if (!schemaHash) throw new Error(`${alias} not found`);
|
||||
|
||||
const varName = `@ocas/template/text/${schemaHash}`;
|
||||
const variable = varStore.get(varName, stringHash);
|
||||
const variable = store.var.get(varName, stringHash);
|
||||
if (variable === null) throw new Error(`Variable ${varName} not found`);
|
||||
|
||||
const templateNode = store.get(variable.value);
|
||||
const templateNode = store.cas.get(variable.value);
|
||||
if (templateNode === null)
|
||||
throw new Error(`Template node ${variable.value} not found`);
|
||||
expect(typeof templateNode.payload).toBe("string");
|
||||
@@ -83,35 +65,34 @@ describe("registerOutputTemplates", () => {
|
||||
});
|
||||
|
||||
test("is idempotent — safe to call multiple times", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
|
||||
store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
const store = createMemoryStore();
|
||||
bootstrap(store);
|
||||
|
||||
const first = await registerOutputTemplates(store, varStore);
|
||||
const second = await registerOutputTemplates(store, varStore);
|
||||
const first = await registerOutputTemplates(store);
|
||||
const second = await registerOutputTemplates(store);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
test("@ocas/output/put template contains payload reference", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
|
||||
store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
const store = createMemoryStore();
|
||||
const aliases = bootstrap(store);
|
||||
|
||||
await registerOutputTemplates(store, varStore);
|
||||
await registerOutputTemplates(store);
|
||||
|
||||
const putHash = aliases["@ocas/output/put"];
|
||||
if (!putHash) throw new Error("@ocas/output/put not found");
|
||||
const stringHash = aliases["@ocas/string"];
|
||||
if (!stringHash) throw new Error("@string not found");
|
||||
if (!stringHash) throw new Error("@ocas/string not found");
|
||||
|
||||
const variable = varStore.get(`@ocas/template/text/${putHash}`, stringHash);
|
||||
const variable = store.var.get(
|
||||
`@ocas/template/text/${putHash}`,
|
||||
stringHash,
|
||||
);
|
||||
if (variable === null)
|
||||
throw new Error("@ocas/output/put template variable not found");
|
||||
|
||||
const templateNode = store.get(variable.value);
|
||||
const templateNode = store.cas.get(variable.value);
|
||||
if (templateNode === null) throw new Error("Template node not found");
|
||||
expect(templateNode.payload).toBe("{{ payload }}");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
|
||||
const DEFAULT_TEMPLATES: ReadonlyArray<
|
||||
readonly [alias: string, template: string]
|
||||
@@ -28,14 +27,18 @@ const DEFAULT_TEMPLATES: ReadonlyArray<
|
||||
"@ocas/output/var-delete",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||
],
|
||||
[
|
||||
"@ocas/output/var-tag",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||
],
|
||||
[
|
||||
"@ocas/output/var-list",
|
||||
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}",
|
||||
],
|
||||
[
|
||||
"@ocas/output/tag",
|
||||
"{% for t in payload %}{{ t.key }}{% if t.value %}:{{ t.value }}{% endif %}\n{% endfor %}",
|
||||
],
|
||||
[
|
||||
"@ocas/output/untag",
|
||||
"{% for t in payload %}{{ t.key }}{% if t.value %}:{{ t.value }}{% endif %}\n{% endfor %}",
|
||||
],
|
||||
[
|
||||
"@ocas/output/var-history",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\n{% for v in payload.values %}{{ forloop.index0 }}: {{ v }}\n{% endfor %}",
|
||||
@@ -58,19 +61,18 @@ const DEFAULT_TEMPLATES: ReadonlyArray<
|
||||
|
||||
/**
|
||||
* Register default LiquidJS templates for all @ocas/output/* schemas.
|
||||
* Each template is stored as a @string CAS node and bound to
|
||||
* Each template is stored as a @ocas/string CAS node and bound to
|
||||
* the variable `@ocas/template/text/<schema-hash>`.
|
||||
*
|
||||
* Idempotent: safe to call multiple times.
|
||||
*/
|
||||
export async function registerOutputTemplates(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
): Promise<Record<string, Hash>> {
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const stringHash = aliases["@ocas/string"];
|
||||
if (stringHash === undefined) {
|
||||
throw new Error("@string schema not found in bootstrap result");
|
||||
throw new Error("@ocas/string schema not found in bootstrap result");
|
||||
}
|
||||
|
||||
const registered: Record<string, Hash> = {};
|
||||
@@ -81,9 +83,9 @@ export async function registerOutputTemplates(
|
||||
throw new Error(`Schema alias not found: ${alias}`);
|
||||
}
|
||||
|
||||
const contentHash = await store.put(stringHash, template);
|
||||
const contentHash = store.cas.put(stringHash, template);
|
||||
const varName = `@ocas/template/text/${schemaHash}`;
|
||||
varStore.set(varName, contentHash);
|
||||
store.var.set(varName, contentHash);
|
||||
registered[alias] = contentHash;
|
||||
}
|
||||
|
||||
|
||||
+143
-143
@@ -1,17 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { CasNodeNotFoundError } from "./errors.js";
|
||||
import { render, renderAsync, renderDirect } from "./render.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Hash } from "./types.js";
|
||||
import { CasNodeNotFoundError } from "./variable-store.js";
|
||||
|
||||
describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
test("1.1 Render Simple Primitives", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "hello");
|
||||
bootstrap(store);
|
||||
const textSchema = putSchema(store, { type: "string" });
|
||||
const hash = store.cas.put(textSchema, "hello");
|
||||
|
||||
const output = render(store, hash, { resolution: 1.0 });
|
||||
|
||||
@@ -21,15 +21,15 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
|
||||
test("1.2 Render Object Node (Flat)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const objSchema = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const objSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
count: { type: "number" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(objSchema, { name: "test", count: 42 });
|
||||
const hash = store.cas.put(objSchema, { name: "test", count: 42 });
|
||||
|
||||
const output = render(store, hash, { resolution: 1.0 });
|
||||
|
||||
@@ -41,12 +41,12 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
|
||||
test("1.3 Render Array Node (Flat)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const arraySchema = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const arraySchema = putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
});
|
||||
const hash = await store.put(arraySchema, [1, 2, 3]);
|
||||
const hash = store.cas.put(arraySchema, [1, 2, 3]);
|
||||
|
||||
const output = render(store, hash, { resolution: 1.0 });
|
||||
|
||||
@@ -57,9 +57,9 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
|
||||
test("1.4 Render with resolution=0 (Force Reference)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "hello");
|
||||
bootstrap(store);
|
||||
const textSchema = putSchema(store, { type: "string" });
|
||||
const hash = store.cas.put(textSchema, "hello");
|
||||
|
||||
const output = render(store, hash, { resolution: 0 });
|
||||
|
||||
@@ -80,24 +80,24 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
describe("Suite 2: Resolution Decay Model", () => {
|
||||
test("2.1 Single-level Nesting with Default Decay", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
const childSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { content: "leaf" });
|
||||
const childHash = store.cas.put(childSchema, { content: "leaf" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
child: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
const parentHash = store.cas.put(parentSchema, {
|
||||
title: "root",
|
||||
child: childHash,
|
||||
});
|
||||
@@ -116,9 +116,9 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
|
||||
test("2.2 Multi-level Nesting Reaches Epsilon", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const leafSchema = await putSchema(store, {
|
||||
const leafSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "number" },
|
||||
@@ -131,7 +131,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
// Create 8-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
currentHash = await store.put(leafSchema, {
|
||||
currentHash = store.cas.put(leafSchema, {
|
||||
value: i,
|
||||
next: currentHash,
|
||||
});
|
||||
@@ -152,9 +152,9 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
|
||||
test("2.3 High Decay (Quick Cutoff)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
const nodeSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
@@ -165,12 +165,12 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
});
|
||||
|
||||
// Create 3-level nested structure
|
||||
const level2Hash = await store.put(nodeSchema, { level: 2, child: null });
|
||||
const level1Hash = await store.put(nodeSchema, {
|
||||
const level2Hash = store.cas.put(nodeSchema, { level: 2, child: null });
|
||||
const level1Hash = store.cas.put(nodeSchema, {
|
||||
level: 1,
|
||||
child: level2Hash,
|
||||
});
|
||||
const rootHash = await store.put(nodeSchema, {
|
||||
const rootHash = store.cas.put(nodeSchema, {
|
||||
level: 0,
|
||||
child: level1Hash,
|
||||
});
|
||||
@@ -190,9 +190,9 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
|
||||
test("2.4 Low Decay (Deep Expansion)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
const nodeSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
@@ -205,7 +205,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
// Create 10-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 9; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
currentHash = store.cas.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
@@ -225,9 +225,9 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
|
||||
test("2.5 Starting Resolution Below 1.0", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
const nodeSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
@@ -240,7 +240,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
// Create 5-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
currentHash = store.cas.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
@@ -263,20 +263,20 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
describe("Suite 3: Complex Graph Structures", () => {
|
||||
test("3.1 Multiple Child References", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const itemSchema = await putSchema(store, {
|
||||
const itemSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const item1 = await store.put(itemSchema, { name: "item1" });
|
||||
const item2 = await store.put(itemSchema, { name: "item2" });
|
||||
const item3 = await store.put(itemSchema, { name: "item3" });
|
||||
const item1 = store.cas.put(itemSchema, { name: "item1" });
|
||||
const item2 = store.cas.put(itemSchema, { name: "item2" });
|
||||
const item3 = store.cas.put(itemSchema, { name: "item3" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
@@ -285,7 +285,7 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
const parentHash = store.cas.put(parentSchema, {
|
||||
items: [item1, item2, item3],
|
||||
});
|
||||
|
||||
@@ -302,19 +302,19 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
|
||||
test("3.2 Object with Multiple ocas_ref Fields", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
const childSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const leftHash = await store.put(childSchema, { value: "left" });
|
||||
const rightHash = await store.put(childSchema, { value: "right" });
|
||||
const leftHash = store.cas.put(childSchema, { value: "left" });
|
||||
const rightHash = store.cas.put(childSchema, { value: "right" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
left: { type: "string", format: "ocas_ref" },
|
||||
@@ -322,7 +322,7 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
data: { type: "string" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
const parentHash = store.cas.put(parentSchema, {
|
||||
left: leftHash,
|
||||
right: rightHash,
|
||||
data: "node",
|
||||
@@ -341,9 +341,9 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
|
||||
test("3.3 Cycle Detection", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
const nodeSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
@@ -353,8 +353,8 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const hashA = await store.put(nodeSchema, { name: "A", ref: null });
|
||||
const hashB = await store.put(nodeSchema, { name: "B", ref: hashA });
|
||||
const hashA = store.cas.put(nodeSchema, { name: "A", ref: null });
|
||||
const hashB = store.cas.put(nodeSchema, { name: "B", ref: hashA });
|
||||
|
||||
// Manually update A to reference B (simulate cycle)
|
||||
// Note: In practice, this requires store manipulation
|
||||
@@ -373,40 +373,40 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
|
||||
test("3.4 DAG (Shared Descendant)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const leafSchema = await putSchema(store, {
|
||||
const leafSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const sharedLeaf = await store.put(leafSchema, { value: "shared" });
|
||||
const sharedLeaf = store.cas.put(leafSchema, { value: "shared" });
|
||||
|
||||
const branchSchema = await putSchema(store, {
|
||||
const branchSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
child: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const branchA = await store.put(branchSchema, {
|
||||
const branchA = store.cas.put(branchSchema, {
|
||||
name: "A",
|
||||
child: sharedLeaf,
|
||||
});
|
||||
const branchB = await store.put(branchSchema, {
|
||||
const branchB = store.cas.put(branchSchema, {
|
||||
name: "B",
|
||||
child: sharedLeaf,
|
||||
});
|
||||
|
||||
const rootSchema = await putSchema(store, {
|
||||
const rootSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
left: { type: "string", format: "ocas_ref" },
|
||||
right: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const rootHash = await store.put(rootSchema, {
|
||||
const rootHash = store.cas.put(rootSchema, {
|
||||
left: branchA,
|
||||
right: branchB,
|
||||
});
|
||||
@@ -424,9 +424,9 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
|
||||
test("3.5 Deep Tree", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
const nodeSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "number" },
|
||||
@@ -442,11 +442,11 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
// Create binary tree (just 5 levels for test speed)
|
||||
async function createTree(depth: number, value: number): Promise<Hash> {
|
||||
if (depth === 0) {
|
||||
return store.put(nodeSchema, { value, left: null, right: null });
|
||||
return store.cas.put(nodeSchema, { value, left: null, right: null });
|
||||
}
|
||||
const left = await createTree(depth - 1, value * 2);
|
||||
const right = await createTree(depth - 1, value * 2 + 1);
|
||||
return store.put(nodeSchema, { value, left, right });
|
||||
return store.cas.put(nodeSchema, { value, left, right });
|
||||
}
|
||||
|
||||
const rootHash = await createTree(5, 1);
|
||||
@@ -465,9 +465,9 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
test("4.1 Resolution Exactly at Epsilon", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "test");
|
||||
bootstrap(store);
|
||||
const textSchema = putSchema(store, { type: "string" });
|
||||
const hash = store.cas.put(textSchema, "test");
|
||||
|
||||
const output = render(store, hash, {
|
||||
resolution: 0.01,
|
||||
@@ -480,9 +480,9 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
|
||||
test("4.2 Resolution Just Above Epsilon", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "test");
|
||||
bootstrap(store);
|
||||
const textSchema = putSchema(store, { type: "string" });
|
||||
const hash = store.cas.put(textSchema, "test");
|
||||
|
||||
const output = render(store, hash, {
|
||||
resolution: 0.0100001,
|
||||
@@ -495,9 +495,9 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
|
||||
test("4.3 Very Small Epsilon (Deep Expansion)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
const nodeSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
@@ -510,7 +510,7 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
// Create 15-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 14; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
currentHash = store.cas.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
@@ -530,9 +530,9 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
|
||||
test("4.4 Zero Epsilon (Never Prune)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
const nodeSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
@@ -545,7 +545,7 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
// Create 20-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 19; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
currentHash = store.cas.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
@@ -567,15 +567,15 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
describe("Suite 5: YAML Output Format", () => {
|
||||
test("5.1 Valid YAML Syntax", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const objSchema = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const objSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
count: { type: "number" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(objSchema, { name: "test", count: 42 });
|
||||
const hash = store.cas.put(objSchema, { name: "test", count: 42 });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -585,8 +585,8 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
|
||||
test("5.2 Nested Object Indentation", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const nestedSchema = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const nestedSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
outer: {
|
||||
@@ -597,7 +597,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const hash = await store.put(nestedSchema, {
|
||||
const hash = store.cas.put(nestedSchema, {
|
||||
outer: { inner: "value" },
|
||||
});
|
||||
|
||||
@@ -611,12 +611,12 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
|
||||
test("5.3 Array Rendering", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const arraySchema = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const arraySchema = putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
});
|
||||
const hash = await store.put(arraySchema, [1, 2, 3]);
|
||||
const hash = store.cas.put(arraySchema, [1, 2, 3]);
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -626,23 +626,23 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
|
||||
test("5.4 CAS Reference in YAML", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
const childSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { value: "child" });
|
||||
const childHash = store.cas.put(childSchema, { value: "child" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
child: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, { child: childHash });
|
||||
const parentHash = store.cas.put(parentSchema, { child: childHash });
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
@@ -656,9 +656,9 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
|
||||
test("5.5 Special Characters Escaping", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "line1\nline2: value");
|
||||
bootstrap(store);
|
||||
const textSchema = putSchema(store, { type: "string" });
|
||||
const hash = store.cas.put(textSchema, "line1\nline2: value");
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -668,8 +668,8 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
|
||||
test("5.6 Null Handling", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const nullableSchema = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const nullableSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: {
|
||||
@@ -677,7 +677,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const hash = await store.put(nullableSchema, { ref: null });
|
||||
const hash = store.cas.put(nullableSchema, { ref: null });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -688,23 +688,23 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
describe("Suite 6: Schema Integration", () => {
|
||||
test("6.1 Detect ocas_ref Fields via Schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
const childSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { value: "child" });
|
||||
const childHash = store.cas.put(childSchema, { value: "child" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
link: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, { link: childHash });
|
||||
const parentHash = store.cas.put(parentSchema, { link: childHash });
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
@@ -717,14 +717,14 @@ describe("Suite 6: Schema Integration", () => {
|
||||
|
||||
test("6.2 Non-ocas_ref String Not Expanded", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const objSchema = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const objSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(objSchema, { name: "ABC123XYZ9012" });
|
||||
const hash = store.cas.put(objSchema, { name: "ABC123XYZ9012" });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -735,22 +735,22 @@ describe("Suite 6: Schema Integration", () => {
|
||||
|
||||
test("6.3 Array of ocas_ref", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const itemSchema = await putSchema(store, {
|
||||
const itemSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
const item1 = await store.put(itemSchema, { name: "item1" });
|
||||
const item2 = await store.put(itemSchema, { name: "item2" });
|
||||
const item1 = store.cas.put(itemSchema, { name: "item1" });
|
||||
const item2 = store.cas.put(itemSchema, { name: "item2" });
|
||||
|
||||
const arraySchema = await putSchema(store, {
|
||||
const arraySchema = putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
});
|
||||
const arrayHash = await store.put(arraySchema, [item1, item2]);
|
||||
const arrayHash = store.cas.put(arraySchema, [item1, item2]);
|
||||
|
||||
const output = render(store, arrayHash, {
|
||||
resolution: 1.0,
|
||||
@@ -764,17 +764,17 @@ describe("Suite 6: Schema Integration", () => {
|
||||
|
||||
test("6.4 anyOf with ocas_ref (Nullable Reference)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
const childSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { value: "child" });
|
||||
const childHash = store.cas.put(childSchema, { value: "child" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: {
|
||||
@@ -782,7 +782,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, { ref: childHash });
|
||||
const parentHash = store.cas.put(parentSchema, { ref: childHash });
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
@@ -795,7 +795,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
|
||||
test("6.5 Schema-less Node (Bootstrap Node)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const types = await bootstrap(store);
|
||||
const types = bootstrap(store);
|
||||
const schemaHash = types["@ocas/schema"];
|
||||
|
||||
const output = render(store, schemaHash);
|
||||
@@ -808,16 +808,16 @@ describe("Suite 6: Schema Integration", () => {
|
||||
describe("Suite 7: Error Handling", () => {
|
||||
test("7.1 Missing Referenced Node", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
child: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
const parentHash = await store.put(parentSchema, { child: fakeChildHash });
|
||||
const parentHash = store.cas.put(parentSchema, { child: fakeChildHash });
|
||||
|
||||
const output = render(store, parentHash);
|
||||
|
||||
@@ -850,8 +850,8 @@ describe("Suite 7: Error Handling", () => {
|
||||
describe("Suite 8: Performance & Edge Cases", () => {
|
||||
test("8.1 Large Payload", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const arraySchema = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const arraySchema = putSchema(store, {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
@@ -866,7 +866,7 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
id: i,
|
||||
name: `item${i}`,
|
||||
}));
|
||||
const hash = await store.put(arraySchema, largeArray);
|
||||
const hash = store.cas.put(arraySchema, largeArray);
|
||||
|
||||
const start = Date.now();
|
||||
const output = render(store, hash);
|
||||
@@ -878,9 +878,9 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
|
||||
test("8.2 Wide Fan-out", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const itemSchema = await putSchema(store, {
|
||||
const itemSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "number" },
|
||||
@@ -889,15 +889,15 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
|
||||
const children: Hash[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const hash = await store.put(itemSchema, { value: i });
|
||||
const hash = store.cas.put(itemSchema, { value: i });
|
||||
children.push(hash);
|
||||
}
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, children);
|
||||
const parentHash = store.cas.put(parentSchema, children);
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
@@ -910,9 +910,9 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
|
||||
test("8.3 Empty Payload", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const emptySchema = await putSchema(store, { type: "object" });
|
||||
const hash = await store.put(emptySchema, {});
|
||||
bootstrap(store);
|
||||
const emptySchema = putSchema(store, { type: "object" });
|
||||
const hash = store.cas.put(emptySchema, {});
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -921,14 +921,14 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
|
||||
test("8.4 Unicode in Payload", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const textSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(textSchema, { text: "你好世界 🌍" });
|
||||
const hash = store.cas.put(textSchema, { text: "你好世界 🌍" });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -986,17 +986,17 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
|
||||
|
||||
test("9.5 Render with store expands ocas_ref fields", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
// Create a child node
|
||||
const childSchema = await putSchema(store, {
|
||||
const childSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { msg: { type: "string" } },
|
||||
});
|
||||
const childHash = await store.put(childSchema, { msg: "inner" });
|
||||
const childHash = store.cas.put(childSchema, { msg: "inner" });
|
||||
|
||||
// Parent schema with ocas_ref
|
||||
const parentSchema = await putSchema(store, {
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
child: { type: "string", format: "ocas_ref" },
|
||||
@@ -1052,7 +1052,7 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
|
||||
|
||||
test("9.10 store present but schema missing — renders without ref expansion", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
const unknownType = "ZZZZZZZZZZZZ0" as Hash;
|
||||
const output = renderDirect(unknownType, { key: "val" }, store, null);
|
||||
expect(output).toContain("key: val");
|
||||
@@ -1062,7 +1062,7 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
|
||||
describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
test("10.1 renderAsync() throws CasNodeNotFoundError for missing root hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
const fakeHash = "AAAAAAAAAAAAA" as Hash;
|
||||
|
||||
await expect(renderAsync(store, fakeHash)).rejects.toThrow(
|
||||
@@ -1093,9 +1093,9 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
|
||||
test("10.4 Missing nested node renders as cas: reference (no error)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
const parentSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
@@ -1104,7 +1104,7 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
});
|
||||
|
||||
const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
const parentHash = store.cas.put(parentSchema, {
|
||||
title: "root",
|
||||
child: fakeChildHash,
|
||||
});
|
||||
@@ -1117,9 +1117,9 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
|
||||
test("10.5 Resolution below epsilon renders as cas: reference (no error)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
const nodeSchema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
@@ -1132,7 +1132,7 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
// Create 3-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 2; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
currentHash = store.cas.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
|
||||
+30
-41
@@ -1,14 +1,12 @@
|
||||
import { CasNodeNotFoundError } from "./errors.js";
|
||||
import { renderWithTemplate } from "./liquid-render.js";
|
||||
import { collectRefs, getSchema, putSchema, refs } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
import { CasNodeNotFoundError } from "./variable-store.js";
|
||||
|
||||
export type RenderOptions = {
|
||||
resolution?: number; // (0, 1], default 1.0
|
||||
decay?: number; // (0, 1], default 0.5
|
||||
epsilon?: number; // >= 0, default 0.01
|
||||
varStore?: VariableStore; // Optional: for template lookup
|
||||
};
|
||||
|
||||
const DEFAULT_RESOLUTION = 1.0;
|
||||
@@ -20,12 +18,11 @@ const FLOAT_TOLERANCE = 1e-10;
|
||||
/**
|
||||
* Extract and validate resolution/decay/epsilon from options.
|
||||
*/
|
||||
function validateAndExtractOptions(
|
||||
options:
|
||||
| Pick<RenderOptions, "resolution" | "decay" | "epsilon">
|
||||
| null
|
||||
| undefined,
|
||||
): { resolution: number; decay: number; epsilon: number } {
|
||||
function validateAndExtractOptions(options: RenderOptions | null | undefined): {
|
||||
resolution: number;
|
||||
decay: number;
|
||||
epsilon: number;
|
||||
} {
|
||||
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
|
||||
const decay = options?.decay ?? DEFAULT_DECAY;
|
||||
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
|
||||
@@ -47,7 +44,7 @@ function validateAndExtractOptions(
|
||||
* Render a CAS node as YAML with resolution-based decay.
|
||||
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
|
||||
* This is the synchronous version without template support.
|
||||
* For template support, use renderAsync() with varStore.
|
||||
* For template support, use renderAsync().
|
||||
*/
|
||||
export function render(
|
||||
store: Store,
|
||||
@@ -57,7 +54,7 @@ export function render(
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
// Check if root node exists
|
||||
if (store.get(hash) === null) {
|
||||
if (store.cas.get(hash) === null) {
|
||||
throw new CasNodeNotFoundError(hash);
|
||||
}
|
||||
|
||||
@@ -68,7 +65,7 @@ export function render(
|
||||
/**
|
||||
* Async render with LiquidJS template support.
|
||||
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
|
||||
* If varStore is provided, attempts to use LiquidJS templates first, fallback to YAML.
|
||||
* Attempts to use LiquidJS templates first, falling back to YAML.
|
||||
*/
|
||||
export async function renderAsync(
|
||||
store: Store,
|
||||
@@ -78,30 +75,26 @@ export async function renderAsync(
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
// Check if root node exists
|
||||
if (store.get(hash) === null) {
|
||||
if (store.cas.get(hash) === null) {
|
||||
throw new CasNodeNotFoundError(hash);
|
||||
}
|
||||
|
||||
const varStore = options?.varStore;
|
||||
|
||||
// If varStore provided, try template rendering first
|
||||
if (varStore !== undefined) {
|
||||
try {
|
||||
const node = store.get(hash);
|
||||
if (node !== null) {
|
||||
// Check if a template exists for this type
|
||||
const templateExists = await hasTemplate(store, varStore, node.type);
|
||||
if (templateExists) {
|
||||
return await renderWithTemplate(store, varStore, hash, {
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
});
|
||||
}
|
||||
// Try template rendering first
|
||||
try {
|
||||
const node = store.cas.get(hash);
|
||||
if (node !== null) {
|
||||
// Check if a template exists for this type
|
||||
const templateExists = await hasTemplate(store, node.type);
|
||||
if (templateExists) {
|
||||
return await renderWithTemplate(store, hash, {
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fall through to YAML rendering
|
||||
}
|
||||
} catch {
|
||||
// Fall through to YAML rendering
|
||||
}
|
||||
|
||||
// Fallback to YAML rendering
|
||||
@@ -119,7 +112,7 @@ export function renderDirect(
|
||||
typeHash: Hash,
|
||||
value: unknown,
|
||||
store: Store | null,
|
||||
options: Omit<RenderOptions, "varStore"> | null,
|
||||
options: RenderOptions | null,
|
||||
): string {
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
@@ -136,7 +129,7 @@ export function renderDirect(
|
||||
const visited = new Set<Hash>();
|
||||
|
||||
return renderValue(
|
||||
store ?? null,
|
||||
store,
|
||||
value,
|
||||
refSet,
|
||||
childResolution,
|
||||
@@ -149,15 +142,11 @@ export function renderDirect(
|
||||
/**
|
||||
* Check if a template exists for a given type
|
||||
*/
|
||||
async function hasTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
typeHash: Hash,
|
||||
): Promise<boolean> {
|
||||
async function hasTemplate(store: Store, typeHash: Hash): Promise<boolean> {
|
||||
const varName = `@ocas/template/text/${typeHash}`;
|
||||
try {
|
||||
const stringSchema = await putSchema(store, { type: "string" });
|
||||
const variable = varStore.get(varName, stringSchema);
|
||||
const stringSchema = putSchema(store, { type: "string" });
|
||||
const variable = store.var.get(varName, stringSchema);
|
||||
return variable !== null;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -178,7 +167,7 @@ function renderNode(
|
||||
}
|
||||
|
||||
// Fetch the node
|
||||
const node = store !== null ? store.get(hash) : null;
|
||||
const node = store !== null ? store.cas.get(hash) : null;
|
||||
if (node === null) {
|
||||
// Missing node - render as cas: reference
|
||||
return `cas:${hash}`;
|
||||
|
||||
+237
-166
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { getSchema, putSchema, refs, validate, walk } from "./schema.js";
|
||||
@@ -11,7 +11,7 @@ import type { CasNode } from "./types.js";
|
||||
describe("putSchema", () => {
|
||||
test("returns a valid 13-char hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, { type: "object", properties: {} });
|
||||
const hash = putSchema(store, { type: "object", properties: {} });
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
@@ -19,20 +19,20 @@ describe("putSchema", () => {
|
||||
test("schema node is stored in the store", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const hash = await putSchema(store, schema);
|
||||
const hash = putSchema(store, schema);
|
||||
|
||||
expect(store.has(hash)).toBe(true);
|
||||
const node = store.get(hash);
|
||||
expect(store.cas.has(hash)).toBe(true);
|
||||
const node = store.cas.get(hash);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.payload).toEqual(schema);
|
||||
});
|
||||
|
||||
test("schema node type equals the meta-schema hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const node = store.get(schemaHash) as CasNode;
|
||||
const schemaHash = putSchema(store, { type: "string" });
|
||||
const node = store.cas.get(schemaHash) as CasNode;
|
||||
|
||||
expect(node.type).toBe(metaHash);
|
||||
});
|
||||
@@ -40,16 +40,16 @@ describe("putSchema", () => {
|
||||
test("putSchema is idempotent: same schema → same hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schema = { type: "number" };
|
||||
const h1 = await putSchema(store, schema);
|
||||
const h2 = await putSchema(store, schema);
|
||||
const h1 = putSchema(store, schema);
|
||||
const h2 = putSchema(store, schema);
|
||||
|
||||
expect(h1).toBe(h2);
|
||||
});
|
||||
|
||||
test("different schemas produce different hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const h1 = await putSchema(store, { type: "string" });
|
||||
const h2 = await putSchema(store, { type: "number" });
|
||||
const h1 = putSchema(store, { type: "string" });
|
||||
const h2 = putSchema(store, { type: "number" });
|
||||
|
||||
expect(h1).not.toBe(h2);
|
||||
});
|
||||
@@ -62,7 +62,7 @@ describe("getSchema", () => {
|
||||
test("returns the original schema object", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schema = { type: "object", properties: { age: { type: "number" } } };
|
||||
const hash = await putSchema(store, schema);
|
||||
const hash = putSchema(store, schema);
|
||||
|
||||
expect(getSchema(store, hash)).toEqual(schema);
|
||||
});
|
||||
@@ -82,7 +82,7 @@ describe("getSchema", () => {
|
||||
label: { type: "string" },
|
||||
},
|
||||
};
|
||||
const hash = await putSchema(store, schema);
|
||||
const hash = putSchema(store, schema);
|
||||
expect(getSchema(store, hash)).toEqual(schema);
|
||||
});
|
||||
});
|
||||
@@ -93,39 +93,39 @@ describe("getSchema", () => {
|
||||
describe("validate", () => {
|
||||
test("returns true when payload matches the schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||
required: ["name"],
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { name: "Alice", age: 30 });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(schemaHash, { name: "Alice", age: 30 });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(validate(store, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when payload violates the schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { count: { type: "number" } },
|
||||
required: ["count"],
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { count: "not-a-number" });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(schemaHash, { count: "not-a-number" });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(validate(store, node)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when required field is missing", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
required: ["title"],
|
||||
properties: { title: { type: "string" } },
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, {});
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(schemaHash, {});
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(validate(store, node)).toBe(false);
|
||||
});
|
||||
@@ -148,19 +148,19 @@ describe("validate", () => {
|
||||
describe("refs", () => {
|
||||
test("returns empty array when schema has no ocas_ref fields", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { title: { type: "string" } },
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { title: "hello" });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(schemaHash, { title: "hello" });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(refs(store, node)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns the ocas_ref hash values from payload", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
parentHash: { type: "string", format: "ocas_ref" },
|
||||
@@ -169,18 +169,18 @@ describe("refs", () => {
|
||||
});
|
||||
|
||||
const targetHash = "AAAAAAAAAAAAA";
|
||||
const nodeHash = await store.put(schemaHash, {
|
||||
const nodeHash = store.cas.put(schemaHash, {
|
||||
parentHash: targetHash,
|
||||
label: "child",
|
||||
});
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(refs(store, node)).toEqual([targetHash]);
|
||||
});
|
||||
|
||||
test("collects multiple ocas_ref fields", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
leftHash: { type: "string", format: "ocas_ref" },
|
||||
@@ -190,11 +190,11 @@ describe("refs", () => {
|
||||
|
||||
const h1 = "AAAAAAAAAAAAA";
|
||||
const h2 = "BBBBBBBBBBBBB";
|
||||
const nodeHash = await store.put(schemaHash, {
|
||||
const nodeHash = store.cas.put(schemaHash, {
|
||||
leftHash: h1,
|
||||
rightHash: h2,
|
||||
});
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
const result = refs(store, node);
|
||||
expect(result).toHaveLength(2);
|
||||
@@ -204,7 +204,7 @@ describe("refs", () => {
|
||||
|
||||
test("skips null/undefined ocas_ref values", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
optionalRef: { type: "string", format: "ocas_ref" },
|
||||
@@ -212,8 +212,8 @@ describe("refs", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const nodeHash = await store.put(schemaHash, { label: "no ref here" });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(schemaHash, { label: "no ref here" });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(refs(store, node)).toEqual([]);
|
||||
});
|
||||
@@ -236,11 +236,11 @@ describe("refs", () => {
|
||||
describe("walk", () => {
|
||||
test("visits a single node with no refs", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { val: { type: "number" } },
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { val: 42 });
|
||||
const nodeHash = store.cas.put(schemaHash, { val: 42 });
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, nodeHash, (hash) => visited.push(hash));
|
||||
@@ -250,7 +250,7 @@ describe("walk", () => {
|
||||
|
||||
test("visits all reachable nodes in a chain A → B → C", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
nextHash: { type: "string", format: "ocas_ref" },
|
||||
@@ -258,9 +258,9 @@ describe("walk", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const hashC = await store.put(schemaHash, { val: 3 });
|
||||
const hashB = await store.put(schemaHash, { nextHash: hashC, val: 2 });
|
||||
const hashA = await store.put(schemaHash, { nextHash: hashB, val: 1 });
|
||||
const hashC = store.cas.put(schemaHash, { val: 3 });
|
||||
const hashB = store.cas.put(schemaHash, { nextHash: hashC, val: 2 });
|
||||
const hashA = store.cas.put(schemaHash, { nextHash: hashB, val: 1 });
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, hashA, (hash) => visited.push(hash));
|
||||
@@ -274,7 +274,7 @@ describe("walk", () => {
|
||||
|
||||
test("handles cycles without infinite loop", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
peerHash: { type: "string", format: "ocas_ref" },
|
||||
@@ -283,23 +283,23 @@ describe("walk", () => {
|
||||
});
|
||||
|
||||
// A → B, B → A (manual cycle by inserting pre-known hash)
|
||||
const hashA = await store.put(schemaHash, { val: 1 });
|
||||
const _hashB = await store.put(schemaHash, { peerHash: hashA, val: 2 });
|
||||
const hashA = store.cas.put(schemaHash, { val: 1 });
|
||||
const _hashB = store.cas.put(schemaHash, { peerHash: hashA, val: 2 });
|
||||
|
||||
// update A to point at B — since store is content-addressed we can't mutate,
|
||||
// so we build a diamond: root → A and root → B, A → C, B → C
|
||||
const hashC = await store.put(schemaHash, { val: 3 });
|
||||
const hashD = await store.put(schemaHash, { peerHash: hashC, val: 4 });
|
||||
const hashE = await store.put(schemaHash, { peerHash: hashC, val: 5 });
|
||||
const hashC = store.cas.put(schemaHash, { val: 3 });
|
||||
const hashD = store.cas.put(schemaHash, { peerHash: hashC, val: 4 });
|
||||
const hashE = store.cas.put(schemaHash, { peerHash: hashC, val: 5 });
|
||||
|
||||
const schemaHash2 = await putSchema(store, {
|
||||
const schemaHash2 = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
leftHash: { type: "string", format: "ocas_ref" },
|
||||
rightHash: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const rootHash = await store.put(schemaHash2, {
|
||||
const rootHash = store.cas.put(schemaHash2, {
|
||||
leftHash: hashD,
|
||||
rightHash: hashE,
|
||||
});
|
||||
@@ -317,11 +317,11 @@ describe("walk", () => {
|
||||
|
||||
test("skips missing hashes gracefully", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { ref: { type: "string", format: "ocas_ref" } },
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { ref: "0000000000000" });
|
||||
const nodeHash = store.cas.put(schemaHash, { ref: "0000000000000" });
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, nodeHash, (hash) => visited.push(hash));
|
||||
@@ -332,11 +332,11 @@ describe("walk", () => {
|
||||
|
||||
test("visitor receives both hash and node", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" } },
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { x: 7 });
|
||||
const nodeHash = store.cas.put(schemaHash, { x: 7 });
|
||||
|
||||
let receivedHash: string | null = null;
|
||||
let receivedNode: CasNode | null = null;
|
||||
@@ -356,33 +356,33 @@ describe("walk", () => {
|
||||
describe("bootstrap meta-schema self-reference", () => {
|
||||
test("metaNode.type === metaHash (self-referencing)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const metaNode = store.get(metaHash) as CasNode;
|
||||
const metaNode = store.cas.get(metaHash) as CasNode;
|
||||
|
||||
expect(metaNode.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
test("schema nodes have type === metaHash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const schemaNode = store.get(schemaHash) as CasNode;
|
||||
const schemaHash = putSchema(store, { type: "string" });
|
||||
const schemaNode = store.cas.get(schemaHash) as CasNode;
|
||||
|
||||
expect(schemaNode.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
test("data nodes have type === schemaHash (not metaHash)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { val: { type: "number" } },
|
||||
});
|
||||
const dataHash = await store.put(schemaHash, { val: 99 });
|
||||
const dataNode = store.get(dataHash) as CasNode;
|
||||
const dataHash = store.cas.put(schemaHash, { val: 99 });
|
||||
const dataNode = store.cas.get(dataHash) as CasNode;
|
||||
|
||||
expect(dataNode.type).toBe(schemaHash);
|
||||
expect(dataNode.type).not.toBe(metaHash);
|
||||
@@ -392,7 +392,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("accepts schema with numeric constraints (minimum/maximum)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "number",
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
@@ -402,19 +402,19 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
// validate a conforming payload
|
||||
const nodeHash = await store.put(hash, 42);
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(hash, 42);
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
expect(validate(store, node)).toBe(true);
|
||||
|
||||
// validate a non-conforming payload
|
||||
const badHash = await store.put(hash, 200);
|
||||
const badNode = store.get(badHash) as CasNode;
|
||||
const badHash = store.cas.put(hash, 200);
|
||||
const badNode = store.cas.get(badHash) as CasNode;
|
||||
expect(validate(store, badNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with string constraints (minLength/maxLength/pattern)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 10,
|
||||
@@ -422,16 +422,16 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const goodHash = await store.put(hash, "hello");
|
||||
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
|
||||
const goodHash = store.cas.put(hash, "hello");
|
||||
expect(validate(store, store.cas.get(goodHash) as CasNode)).toBe(true);
|
||||
|
||||
const badHash = await store.put(hash, "HELLO");
|
||||
expect(validate(store, store.get(badHash) as CasNode)).toBe(false);
|
||||
const badHash = store.cas.put(hash, "HELLO");
|
||||
expect(validate(store, store.cas.get(badHash) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with array constraints (minItems/maxItems/uniqueItems)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
minItems: 1,
|
||||
@@ -440,32 +440,32 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const goodHash = await store.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
|
||||
const goodHash = store.cas.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.cas.get(goodHash) as CasNode)).toBe(true);
|
||||
|
||||
const tooMany = await store.put(hash, [1, 2, 3, 4]);
|
||||
expect(validate(store, store.get(tooMany) as CasNode)).toBe(false);
|
||||
const tooMany = store.cas.put(hash, [1, 2, 3, 4]);
|
||||
expect(validate(store, store.cas.get(tooMany) as CasNode)).toBe(false);
|
||||
|
||||
const dupes = await store.put(hash, [1, 1]);
|
||||
expect(validate(store, store.get(dupes) as CasNode)).toBe(false);
|
||||
const dupes = store.cas.put(hash, [1, 1]);
|
||||
expect(validate(store, store.cas.get(dupes) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects schema with wrong constraint types", async () => {
|
||||
const store = createMemoryStore();
|
||||
await expect(
|
||||
expect(() =>
|
||||
putSchema(store, { type: "number", minimum: "zero" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
putSchema(store, { type: "string", maxLength: true } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
putSchema(store, { type: "array", uniqueItems: 1 } as never),
|
||||
).rejects.toThrow();
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("accepts schema with nested property constraints", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1, maxLength: 50 },
|
||||
@@ -481,24 +481,24 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, {
|
||||
const good = store.cas.put(hash, {
|
||||
name: "Alice",
|
||||
age: 30,
|
||||
scores: [95, 87],
|
||||
});
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("bootstrap is idempotent across putSchema calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
await putSchema(store, { type: "string" });
|
||||
await putSchema(store, { type: "number" });
|
||||
putSchema(store, { type: "string" });
|
||||
putSchema(store, { type: "number" });
|
||||
|
||||
// bootstrap node should still be there and unchanged
|
||||
const metaNode = store.get(metaHash) as CasNode;
|
||||
const metaNode = store.cas.get(metaHash) as CasNode;
|
||||
expect(metaNode.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
@@ -506,7 +506,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("accepts schema with allOf", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
allOf: [
|
||||
{ type: "object", properties: { name: { type: "string" } } },
|
||||
{ required: ["name"] },
|
||||
@@ -514,16 +514,16 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { name: "Alice" });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, { name: "Alice" });
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, {});
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
const bad = store.cas.put(hash, {});
|
||||
expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with if/then/else", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string" },
|
||||
@@ -539,7 +539,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("accepts schema with patternProperties", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "object",
|
||||
patternProperties: {
|
||||
"^x-": { type: "string" },
|
||||
@@ -547,13 +547,13 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { "x-custom": "hello" });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, { "x-custom": "hello" });
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts schema with prefixItems (tuple)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "string" }, { type: "number" }],
|
||||
});
|
||||
@@ -562,38 +562,38 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("accepts schema with multipleOf", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "number",
|
||||
multipleOf: 5,
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, 15);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, 15);
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, 7);
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
const bad = store.cas.put(hash, 7);
|
||||
expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with minProperties/maxProperties", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "object",
|
||||
minProperties: 1,
|
||||
maxProperties: 3,
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { a: 1, b: 2 });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, { a: 1, b: 2 });
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const empty = await store.put(hash, {});
|
||||
expect(validate(store, store.get(empty) as CasNode)).toBe(false);
|
||||
const empty = store.cas.put(hash, {});
|
||||
expect(validate(store, store.cas.get(empty) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with default value", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "string",
|
||||
default: "hello",
|
||||
});
|
||||
@@ -602,21 +602,17 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("rejects invalid P2 keyword types", async () => {
|
||||
const store = createMemoryStore();
|
||||
await expect(
|
||||
putSchema(store, { allOf: "not-array" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
putSchema(store, { multipleOf: "five" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
expect(() => putSchema(store, { allOf: "not-array" } as never)).toThrow();
|
||||
expect(() => putSchema(store, { multipleOf: "five" } as never)).toThrow();
|
||||
expect(() =>
|
||||
putSchema(store, { patternProperties: [1, 2] } as never),
|
||||
).rejects.toThrow();
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("collectRefs traverses allOf sub-schemas", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
const innerSchema = putSchema(store, { type: "string" });
|
||||
const schema = putSchema(store, {
|
||||
allOf: [
|
||||
{
|
||||
type: "object",
|
||||
@@ -625,41 +621,41 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "target");
|
||||
const nodeHash = await store.put(schema, { ref: targetHash });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const targetHash = store.cas.put(innerSchema, "target");
|
||||
const nodeHash = store.cas.put(schema, { ref: targetHash });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
|
||||
test("collectRefs traverses patternProperties", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
const innerSchema = putSchema(store, { type: "string" });
|
||||
const schema = putSchema(store, {
|
||||
type: "object",
|
||||
patternProperties: {
|
||||
"^ref_": { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "hello");
|
||||
const nodeHash = await store.put(schema, { ref_a: targetHash });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const targetHash = store.cas.put(innerSchema, "hello");
|
||||
const nodeHash = store.cas.put(schema, { ref_a: targetHash });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
|
||||
test("collectRefs traverses prefixItems", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
const innerSchema = putSchema(store, { type: "string" });
|
||||
const schema = putSchema(store, {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "string", format: "ocas_ref" }, { type: "number" }],
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "hello");
|
||||
const nodeHash = await store.put(schema, [targetHash, 42]);
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const targetHash = store.cas.put(innerSchema, "hello");
|
||||
const nodeHash = store.cas.put(schema, [targetHash, 42]);
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
@@ -668,51 +664,51 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("accepts schema with not", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
not: { type: "string" },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, 42);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, 42);
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, "hello");
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
const bad = store.cas.put(hash, "hello");
|
||||
expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with contains", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "array",
|
||||
contains: { type: "number", minimum: 10 },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, [1, 2, 15]);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, [1, 2, 15]);
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
const bad = store.cas.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with propertyNames", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "object",
|
||||
propertyNames: { pattern: "^[a-z]+$" },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { foo: 1, bar: 2 });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, { foo: 1, bar: 2 });
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, { Foo: 1 });
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
const bad = store.cas.put(hash, { Foo: 1 });
|
||||
expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with metadata keywords", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
const hash = putSchema(store, {
|
||||
type: "string",
|
||||
examples: ["hello", "world"],
|
||||
readOnly: true,
|
||||
@@ -724,30 +720,105 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
|
||||
test("rejects invalid P3 keyword types", async () => {
|
||||
const store = createMemoryStore();
|
||||
await expect(
|
||||
putSchema(store, { not: "not-object" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
expect(() => putSchema(store, { not: "not-object" } as never)).toThrow();
|
||||
expect(() =>
|
||||
putSchema(store, { examples: "not-array" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
putSchema(store, { readOnly: "yes" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(putSchema(store, { $comment: 42 } as never)).rejects.toThrow();
|
||||
).toThrow();
|
||||
expect(() => putSchema(store, { readOnly: "yes" } as never)).toThrow();
|
||||
expect(() => putSchema(store, { $comment: 42 } as never)).toThrow();
|
||||
});
|
||||
|
||||
test("collectRefs traverses contains", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
const innerSchema = putSchema(store, { type: "string" });
|
||||
const schema = putSchema(store, {
|
||||
type: "array",
|
||||
contains: { type: "string", format: "ocas_ref" },
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "hello");
|
||||
const nodeHash = await store.put(schema, [targetHash, "not-a-ref"]);
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const targetHash = store.cas.put(innerSchema, "hello");
|
||||
const nodeHash = store.cas.put(schema, [targetHash, "not-a-ref"]);
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Suite A: collectRefs() oneOf traversal (issue #93)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("collectRefs oneOf traversal", () => {
|
||||
test("A.1 returns the ref hash from the chosen oneOf branch (string variant)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = putSchema(store, { type: "string" });
|
||||
const schema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
prev: {
|
||||
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const targetHash = store.cas.put(innerSchema, "target");
|
||||
const nodeHash = store.cas.put(schema, { prev: targetHash });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(refs(store, node)).toContain(targetHash);
|
||||
});
|
||||
|
||||
test("A.2 returns no ref when oneOf matches the null variant", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schema = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
prev: {
|
||||
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const nodeHash = store.cas.put(schema, { prev: null });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(refs(store, node)).toEqual([]);
|
||||
});
|
||||
|
||||
test("A.3 traverses nested combinators inside oneOf", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = putSchema(store, { type: "string" });
|
||||
const schema = putSchema(store, {
|
||||
oneOf: [
|
||||
{ type: "null" },
|
||||
{
|
||||
type: "object",
|
||||
properties: { ref: { type: "string", format: "ocas_ref" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const targetHash = store.cas.put(innerSchema, "target");
|
||||
const nodeHash = store.cas.put(schema, { ref: targetHash });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(refs(store, node)).toContain(targetHash);
|
||||
});
|
||||
|
||||
test("A.4 multiple ref branches in oneOf all surface", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = putSchema(store, { type: "string" });
|
||||
const schema = putSchema(store, {
|
||||
oneOf: [
|
||||
{ type: "string", format: "ocas_ref" },
|
||||
{ type: "string", format: "ocas_ref" },
|
||||
],
|
||||
});
|
||||
|
||||
const targetHash = store.cas.put(innerSchema, "target");
|
||||
const nodeHash = store.cas.put(schema, targetHash);
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
const result = refs(store, node);
|
||||
expect(result).toContain(targetHash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,11 +248,8 @@ function isMetaSchemaNode(store: Store, node: CasNode): boolean {
|
||||
* Store a JSON Schema as a CAS node typed by the meta-schema hash.
|
||||
* The returned hash becomes the typeHash for nodes that conform to this schema.
|
||||
*/
|
||||
export async function putSchema(
|
||||
store: Store,
|
||||
jsonSchema: JSONSchema,
|
||||
): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
export function putSchema(store: Store, jsonSchema: JSONSchema): Hash {
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"];
|
||||
if (!metaHash) {
|
||||
throw new Error("Meta-schema not found in bootstrap result");
|
||||
@@ -262,7 +259,7 @@ export async function putSchema(
|
||||
"Invalid schema: input does not conform to the ocas JSON Schema meta-schema",
|
||||
);
|
||||
}
|
||||
return store.put(metaHash, jsonSchema);
|
||||
return store.cas.put(metaHash, jsonSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,7 +267,7 @@ export async function putSchema(
|
||||
* Returns null if no node exists at that hash.
|
||||
*/
|
||||
export function getSchema(store: Store, typeHash: Hash): JSONSchema | null {
|
||||
const node = store.get(typeHash);
|
||||
const node = store.cas.get(typeHash);
|
||||
if (node === null) return null;
|
||||
return node.payload as JSONSchema;
|
||||
}
|
||||
@@ -314,6 +311,17 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
// oneOf — JSON Schema requires exactly one branch to validate, but for
|
||||
// ref collection we conservatively traverse every branch (the meta-schema
|
||||
// accepts oneOf alongside anyOf, and we cannot statically know which
|
||||
// branch the value will match). Mirrors anyOf handling.
|
||||
if (Array.isArray(schema.oneOf)) {
|
||||
for (const sub of schema.oneOf as JSONSchema[]) {
|
||||
result.push(...collectRefs(sub, value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// P2: allOf — each sub-schema applies to the same value
|
||||
if (Array.isArray(schema.allOf)) {
|
||||
for (const sub of schema.allOf as JSONSchema[]) {
|
||||
@@ -440,7 +448,7 @@ export function walk(
|
||||
if (visited.has(hash)) continue;
|
||||
visited.add(hash);
|
||||
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
if (node === null) continue;
|
||||
|
||||
visitor(hash, node);
|
||||
|
||||
+115
-49
@@ -1,89 +1,155 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
describe("createMemoryStore – meta and schema indexes", () => {
|
||||
describe("A. createMemoryStore – shape", () => {
|
||||
test("A1. returns an object with cas, var, tag sub-stores", () => {
|
||||
const store = createMemoryStore();
|
||||
expect(typeof store.cas).toBe("object");
|
||||
expect(store.cas).not.toBeNull();
|
||||
expect(typeof store.var).toBe("object");
|
||||
expect(store.var).not.toBeNull();
|
||||
expect(typeof store.tag).toBe("object");
|
||||
expect(store.tag).not.toBeNull();
|
||||
});
|
||||
|
||||
test("A2. cas/var/tag are independent objects", () => {
|
||||
const store = createMemoryStore();
|
||||
expect(store.cas).not.toBe(store.var as unknown as typeof store.cas);
|
||||
expect(store.cas).not.toBe(store.tag as unknown as typeof store.cas);
|
||||
expect(store.var).not.toBe(store.tag as unknown as typeof store.var);
|
||||
});
|
||||
});
|
||||
|
||||
describe("B. cas sub-store – meta and schema indexes", () => {
|
||||
test("B1. listMeta and listSchemas are empty on a fresh store", () => {
|
||||
const store = createMemoryStore();
|
||||
expect(store.listMeta()).toEqual([]);
|
||||
expect(store.listSchemas()).toEqual([]);
|
||||
expect(store.cas.listMeta()).toEqual([]);
|
||||
expect(store.cas.listSchemas()).toEqual([]);
|
||||
});
|
||||
|
||||
test("B2. self-referencing put adds hash to metaSet and listSchemas", async () => {
|
||||
test("B2. self-referencing put adds hash to metaSet and listSchemas", () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
|
||||
expect(store.listMeta().map((e) => e.hash)).toContain(hash);
|
||||
expect(store.listSchemas().map((e) => e.hash)).toContain(hash);
|
||||
const hash = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
|
||||
expect(store.cas.listMeta().map((e) => e.hash)).toContain(hash);
|
||||
expect(store.cas.listSchemas().map((e) => e.hash)).toContain(hash);
|
||||
});
|
||||
|
||||
test("B3. regular put does not add hash to metaSet", async () => {
|
||||
test("B3. regular put does not add hash to metaSet", () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const schemaHash = await store.put(metaHash, { type: "string" });
|
||||
|
||||
expect(store.listMeta().map((e) => e.hash)).not.toContain(schemaHash);
|
||||
expect(store.listMeta().map((e) => e.hash)).toContain(metaHash);
|
||||
const metaHash = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
|
||||
const schemaHash = store.cas.put(metaHash, { type: "string" });
|
||||
expect(store.cas.listMeta().map((e) => e.hash)).not.toContain(schemaHash);
|
||||
expect(store.cas.listMeta().map((e) => e.hash)).toContain(metaHash);
|
||||
});
|
||||
|
||||
test("B4. schema typed by meta-schema appears in listSchemas", async () => {
|
||||
test("B4. schema typed by meta-schema appears in listSchemas", () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const s = await store.put(m, { type: "string" });
|
||||
|
||||
const schemas = store.listSchemas().map((e) => e.hash);
|
||||
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
|
||||
const s = store.cas.put(m, { type: "string" });
|
||||
const schemas = store.cas.listSchemas().map((e) => e.hash);
|
||||
expect(schemas).toContain(m);
|
||||
expect(schemas).toContain(s);
|
||||
|
||||
const meta = store.listMeta().map((e) => e.hash);
|
||||
const meta = store.cas.listMeta().map((e) => e.hash);
|
||||
expect(meta).toContain(m);
|
||||
expect(meta).not.toContain(s);
|
||||
});
|
||||
|
||||
test("B5. multiple meta-schemas (versioning)", async () => {
|
||||
test("B5. multiple meta-schemas (versioning)", () => {
|
||||
const store = createMemoryStore();
|
||||
const m1 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v1" });
|
||||
const m2 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v2" });
|
||||
const s1 = await store.put(m1, { type: "string" });
|
||||
const s2 = await store.put(m2, { type: "number" });
|
||||
|
||||
const meta = store.listMeta().map((e) => e.hash);
|
||||
const m1 = store.cas[BOOTSTRAP_STORE]({
|
||||
type: "object",
|
||||
title: "v1",
|
||||
}) as string;
|
||||
const m2 = store.cas[BOOTSTRAP_STORE]({
|
||||
type: "object",
|
||||
title: "v2",
|
||||
}) as string;
|
||||
const s1 = store.cas.put(m1, { type: "string" });
|
||||
const s2 = store.cas.put(m2, { type: "number" });
|
||||
const meta = store.cas.listMeta().map((e) => e.hash);
|
||||
expect(meta).toContain(m1);
|
||||
expect(meta).toContain(m2);
|
||||
expect(meta).toHaveLength(2);
|
||||
|
||||
const schemas = store.listSchemas().map((e) => e.hash);
|
||||
const schemas = store.cas.listSchemas().map((e) => e.hash);
|
||||
expect(schemas).toContain(m1);
|
||||
expect(schemas).toContain(m2);
|
||||
expect(schemas).toContain(s1);
|
||||
expect(schemas).toContain(s2);
|
||||
});
|
||||
|
||||
test("B6. idempotent self-referencing put does not duplicate", async () => {
|
||||
test("B6. idempotent self-referencing put does not duplicate", () => {
|
||||
const store = createMemoryStore();
|
||||
const payload = { type: "object", title: "dup" };
|
||||
const h1 = await store[BOOTSTRAP_STORE](payload);
|
||||
const h2 = await store[BOOTSTRAP_STORE](payload);
|
||||
const h1 = store.cas[BOOTSTRAP_STORE](payload) as string;
|
||||
const h2 = store.cas[BOOTSTRAP_STORE](payload) as string;
|
||||
expect(h1).toBe(h2);
|
||||
|
||||
const meta = store.listMeta().map((e) => e.hash);
|
||||
const occurrences = meta.filter((h) => h === h1).length;
|
||||
expect(occurrences).toBe(1);
|
||||
const meta = store.cas.listMeta().map((e) => e.hash);
|
||||
expect(meta.filter((h) => h === h1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("B7. delete removes hash from metaSet and listSchemas", async () => {
|
||||
test("B7. delete removes hash from metaSet and listSchemas", () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const s = await store.put(m, { type: "string" });
|
||||
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
|
||||
const s = store.cas.put(m, { type: "string" });
|
||||
expect(store.cas.listMeta().map((e) => e.hash)).toContain(m);
|
||||
expect(store.cas.listSchemas().map((e) => e.hash)).toContain(s);
|
||||
store.cas.delete(m);
|
||||
expect(store.cas.listMeta().map((e) => e.hash)).not.toContain(m);
|
||||
expect(store.cas.listSchemas().map((e) => e.hash)).not.toContain(s);
|
||||
expect(store.cas.listSchemas().map((e) => e.hash)).not.toContain(m);
|
||||
});
|
||||
|
||||
expect(store.listMeta().map((e) => e.hash)).toContain(m);
|
||||
expect(store.listSchemas().map((e) => e.hash)).toContain(s);
|
||||
test("B8. cas.put returns Hash synchronously (not a Promise)", () => {
|
||||
const store = createMemoryStore();
|
||||
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
|
||||
const result = store.cas.put(m, { type: "string" });
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toHaveLength(13);
|
||||
});
|
||||
|
||||
store.delete(m);
|
||||
|
||||
expect(store.listMeta().map((e) => e.hash)).not.toContain(m);
|
||||
// schemas typed by deleted meta no longer surface
|
||||
expect(store.listSchemas().map((e) => e.hash)).not.toContain(s);
|
||||
expect(store.listSchemas().map((e) => e.hash)).not.toContain(m);
|
||||
test("B9. has/get/delete reflect put state", () => {
|
||||
const store = createMemoryStore();
|
||||
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
|
||||
const h = store.cas.put(m, { type: "string" });
|
||||
expect(store.cas.has(h)).toBe(true);
|
||||
expect(store.cas.get(h)?.payload).toEqual({ type: "string" });
|
||||
expect(store.cas.has("0000000000000")).toBe(false);
|
||||
expect(store.cas.get("0000000000000")).toBeNull();
|
||||
expect(store.cas.delete(h)).toBe(true);
|
||||
expect(store.cas.delete(h)).toBe(false);
|
||||
expect(store.cas.has(h)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("E. cross-store independence", () => {
|
||||
test("E1. tagging a hash does not surface it in cas.listByType", () => {
|
||||
const store = createMemoryStore();
|
||||
const fakeTarget = "0000000000000";
|
||||
store.tag.tag(fakeTarget, [{ op: "set", key: "env", value: "prod" }]);
|
||||
expect(store.cas.listByType(fakeTarget)).toEqual([]);
|
||||
});
|
||||
|
||||
test("E2. setting a variable does not mutate the CAS node", () => {
|
||||
const store = createMemoryStore();
|
||||
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
|
||||
const s = store.cas.put(m, { type: "string" });
|
||||
const value = store.cas.put(s, "hello");
|
||||
const before = store.cas.get(value);
|
||||
store.var.set("@app/x", value);
|
||||
const after = store.cas.get(value);
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
test("E3. cas.delete does not cascade-delete a variable referencing it", () => {
|
||||
const store = createMemoryStore();
|
||||
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
|
||||
const s = store.cas.put(m, { type: "string" });
|
||||
const value = store.cas.put(s, "hello");
|
||||
store.var.set("@app/x", value);
|
||||
store.cas.delete(value);
|
||||
const got = store.var.get("@app/x", s);
|
||||
expect(got).not.toBeNull();
|
||||
expect(got?.value).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
+391
-21
@@ -2,11 +2,53 @@ import {
|
||||
BOOTSTRAP_STORE,
|
||||
type BootstrapCapableStore,
|
||||
} from "./bootstrap-capable.js";
|
||||
import { computeHash, computeSelfHash } from "./hash.js";
|
||||
import { SchemaMismatchError, VariableNotFoundError } from "./errors.js";
|
||||
import { computeHashSync, computeSelfHashSync, initHasher } from "./hash.js";
|
||||
import { applyListOptions, casListEntry } from "./list-utils.js";
|
||||
import type { CasNode, Hash, ListEntry, ListOptions } from "./types.js";
|
||||
import type {
|
||||
CasNode,
|
||||
CasStore,
|
||||
Hash,
|
||||
HistoryEntry,
|
||||
ListEntry,
|
||||
ListOptions,
|
||||
Store,
|
||||
Tag,
|
||||
TagOp,
|
||||
TagStore,
|
||||
VarListOptions,
|
||||
VarSetOptions,
|
||||
VarStore,
|
||||
} from "./types.js";
|
||||
import { validateName } from "./validation.js";
|
||||
import {
|
||||
addNameIndex,
|
||||
checkTagLabelConflict,
|
||||
cloneVarRecord,
|
||||
extractSchema,
|
||||
pushHistory,
|
||||
removeNameIndex,
|
||||
type VarRecord,
|
||||
varKey,
|
||||
} from "./var-store-helpers.js";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
export function createMemoryStore(): BootstrapCapableStore {
|
||||
// Initialise the xxhash WASM instance once at module load. This allows the
|
||||
// CAS sub-store's `put` method to be synchronous (per the new CasStore type).
|
||||
await initHasher();
|
||||
|
||||
/**
|
||||
* The cas sub-store of an in-memory `Store` — also satisfies the legacy
|
||||
* `BootstrapCapableStore` interface so that helpers that have not yet been
|
||||
* refactored (e.g. bootstrap, gc, render) continue to work against
|
||||
* `store.cas`.
|
||||
*/
|
||||
export type MemoryCasStore = BootstrapCapableStore & {
|
||||
put(typeHash: Hash, payload: unknown): Hash;
|
||||
delete(hash: Hash): boolean;
|
||||
};
|
||||
|
||||
function createCasStore(): MemoryCasStore {
|
||||
const data = new Map<Hash, CasNode>();
|
||||
const byType = new Map<Hash, Set<Hash>>();
|
||||
const metaSet = new Set<Hash>();
|
||||
@@ -20,8 +62,8 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
set.add(hash);
|
||||
}
|
||||
|
||||
async function putSelfReferencing(payload: unknown): Promise<Hash> {
|
||||
const hash = await computeSelfHash(payload);
|
||||
function putSelfReferencing(payload: unknown): Hash {
|
||||
const hash = computeSelfHashSync(payload);
|
||||
if (!data.has(hash)) {
|
||||
data.set(hash, { type: hash, payload, timestamp: Date.now() });
|
||||
indexHash(hash, hash);
|
||||
@@ -39,15 +81,13 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
return result;
|
||||
}
|
||||
|
||||
const store: BootstrapCapableStore = {
|
||||
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
|
||||
const store: MemoryCasStore = {
|
||||
put(typeHash: Hash, payload: unknown): Hash {
|
||||
const hash = computeHashSync(typeHash, payload);
|
||||
if (!data.has(hash)) {
|
||||
data.set(hash, { type: typeHash, payload, timestamp: Date.now() });
|
||||
indexHash(typeHash, hash);
|
||||
}
|
||||
|
||||
return hash;
|
||||
},
|
||||
|
||||
@@ -85,20 +125,19 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
return applyListOptions(entriesForHashes(result), options);
|
||||
},
|
||||
|
||||
delete(hash: Hash): void {
|
||||
delete(hash: Hash): boolean {
|
||||
const node = data.get(hash);
|
||||
if (node) {
|
||||
data.delete(hash);
|
||||
// Remove from type index
|
||||
const set = byType.get(node.type);
|
||||
if (set) {
|
||||
set.delete(hash);
|
||||
if (set.size === 0) {
|
||||
byType.delete(node.type);
|
||||
}
|
||||
if (!node) return false;
|
||||
data.delete(hash);
|
||||
const set = byType.get(node.type);
|
||||
if (set) {
|
||||
set.delete(hash);
|
||||
if (set.size === 0) {
|
||||
byType.delete(node.type);
|
||||
}
|
||||
metaSet.delete(hash);
|
||||
}
|
||||
metaSet.delete(hash);
|
||||
return true;
|
||||
},
|
||||
|
||||
[BOOTSTRAP_STORE]: putSelfReferencing,
|
||||
@@ -106,3 +145,334 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory `VarStore` backed by the supplied CAS store. Exposed so
|
||||
* non-Memory CAS stores (e.g. the FS store) can compose a full `Store`
|
||||
* without re-implementing variable storage.
|
||||
*/
|
||||
export function createMemoryVarStoreFor(cas: CasStore): VarStore {
|
||||
// composite key: `${name}\u0000${schema}`
|
||||
const records = new Map<string, VarRecord>();
|
||||
const byName = new Map<string, Set<string>>(); // name -> set of composite keys
|
||||
|
||||
const varStore: VarStore = {
|
||||
set(name: string, hash: Hash, options?: VarSetOptions): Variable {
|
||||
validateName(name);
|
||||
const schema = extractSchema(cas, hash);
|
||||
const k = varKey(name, schema);
|
||||
const existing = records.get(k);
|
||||
const now = Date.now();
|
||||
|
||||
if (existing) {
|
||||
const tags = options?.tags ?? existing.tags;
|
||||
const labels = options?.labels ?? existing.labels;
|
||||
if (options !== undefined) checkTagLabelConflict(tags, labels);
|
||||
const changed = pushHistory(existing, hash, now);
|
||||
if (changed) {
|
||||
existing.value = hash;
|
||||
existing.updated = now;
|
||||
}
|
||||
if (options !== undefined) {
|
||||
existing.tags = { ...tags };
|
||||
existing.labels = [...labels];
|
||||
}
|
||||
return cloneVarRecord(existing);
|
||||
}
|
||||
|
||||
const tags = options?.tags ?? {};
|
||||
const labels = options?.labels ?? [];
|
||||
checkTagLabelConflict(tags, labels);
|
||||
const rec: VarRecord = {
|
||||
name,
|
||||
schema,
|
||||
value: hash,
|
||||
created: now,
|
||||
updated: now,
|
||||
tags: { ...tags },
|
||||
labels: [...labels],
|
||||
history: [{ value: hash, position: 0, setAt: now }],
|
||||
};
|
||||
records.set(k, rec);
|
||||
addNameIndex(byName, name, k);
|
||||
return cloneVarRecord(rec);
|
||||
},
|
||||
|
||||
get(name: string, schema?: Hash): Variable | null {
|
||||
if (schema !== undefined) {
|
||||
const rec = records.get(varKey(name, schema));
|
||||
return rec ? cloneVarRecord(rec) : null;
|
||||
}
|
||||
// No schema: if exactly one variant, return it; otherwise null
|
||||
const set = byName.get(name);
|
||||
if (!set || set.size !== 1) return null;
|
||||
const onlyKey = set.values().next().value;
|
||||
if (onlyKey === undefined) return null;
|
||||
const rec = records.get(onlyKey);
|
||||
return rec ? cloneVarRecord(rec) : null;
|
||||
},
|
||||
|
||||
remove(name: string, schema?: Hash): Variable[] {
|
||||
if (schema !== undefined) {
|
||||
const k = varKey(name, schema);
|
||||
const rec = records.get(k);
|
||||
if (!rec) return [];
|
||||
records.delete(k);
|
||||
removeNameIndex(byName, name, k);
|
||||
return [cloneVarRecord(rec)];
|
||||
}
|
||||
const set = byName.get(name);
|
||||
if (!set) return [];
|
||||
const removed: Variable[] = [];
|
||||
for (const k of [...set]) {
|
||||
const rec = records.get(k);
|
||||
if (rec) {
|
||||
removed.push(cloneVarRecord(rec));
|
||||
records.delete(k);
|
||||
}
|
||||
}
|
||||
byName.delete(name);
|
||||
return removed;
|
||||
},
|
||||
|
||||
update(name: string, hash: Hash, options?: VarSetOptions): Variable {
|
||||
validateName(name);
|
||||
const newSchema = extractSchema(cas, hash);
|
||||
// Find existing record by name; require existing schema match new schema
|
||||
const set = byName.get(name);
|
||||
if (!set || set.size === 0) {
|
||||
throw new VariableNotFoundError(name, newSchema);
|
||||
}
|
||||
// find a record matching newSchema
|
||||
const k = varKey(name, newSchema);
|
||||
const existing = records.get(k);
|
||||
if (!existing) {
|
||||
// Find any existing — schema mismatch
|
||||
for (const ek of set) {
|
||||
const erec = records.get(ek);
|
||||
if (erec) {
|
||||
throw new SchemaMismatchError(erec.schema, newSchema);
|
||||
}
|
||||
}
|
||||
throw new VariableNotFoundError(name, newSchema);
|
||||
}
|
||||
const now = Date.now();
|
||||
const tags = options?.tags ?? existing.tags;
|
||||
const labels = options?.labels ?? existing.labels;
|
||||
if (options !== undefined) checkTagLabelConflict(tags, labels);
|
||||
const changed = pushHistory(existing, hash, now);
|
||||
if (changed) {
|
||||
existing.value = hash;
|
||||
existing.updated = now;
|
||||
}
|
||||
if (options !== undefined) {
|
||||
existing.tags = { ...tags };
|
||||
existing.labels = [...labels];
|
||||
}
|
||||
return cloneVarRecord(existing);
|
||||
},
|
||||
|
||||
list(options?: VarListOptions): Variable[] {
|
||||
if (
|
||||
options?.namePrefix !== undefined &&
|
||||
options?.exactName !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"namePrefix and exactName are mutually exclusive - cannot specify both",
|
||||
);
|
||||
}
|
||||
const namePrefix = options?.namePrefix;
|
||||
const exactName = options?.exactName;
|
||||
const schema = options?.schema;
|
||||
const filterTags = options?.tags ?? {};
|
||||
const filterLabels = options?.labels ?? [];
|
||||
const sort = options?.sort ?? "created";
|
||||
const desc = options?.desc ?? false;
|
||||
const limit = options?.limit;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
if (limit !== undefined && limit <= 0) return [];
|
||||
|
||||
let results: VarRecord[] = [];
|
||||
for (const rec of records.values()) {
|
||||
if (exactName !== undefined && rec.name !== exactName) continue;
|
||||
if (namePrefix !== undefined && !rec.name.startsWith(namePrefix))
|
||||
continue;
|
||||
if (schema !== undefined && rec.schema !== schema) continue;
|
||||
let ok = true;
|
||||
for (const [tk, tv] of Object.entries(filterTags)) {
|
||||
if (rec.tags[tk] !== tv) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) continue;
|
||||
for (const lb of filterLabels) {
|
||||
if (!rec.labels.includes(lb)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) continue;
|
||||
results.push(rec);
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
const av = sort === "updated" ? a.updated : a.created;
|
||||
const bv = sort === "updated" ? b.updated : b.created;
|
||||
if (av !== bv) return desc ? bv - av : av - bv;
|
||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
||||
});
|
||||
|
||||
if (offset > 0) results = results.slice(offset);
|
||||
if (limit !== undefined) results = results.slice(0, limit);
|
||||
return results.map(cloneVarRecord);
|
||||
},
|
||||
|
||||
history(name: string, schema?: Hash): HistoryEntry[] {
|
||||
if (schema !== undefined) {
|
||||
const rec = records.get(varKey(name, schema));
|
||||
return rec ? rec.history.map((e) => ({ ...e })) : [];
|
||||
}
|
||||
const set = byName.get(name);
|
||||
if (!set || set.size !== 1) return [];
|
||||
const onlyKey = set.values().next().value;
|
||||
if (onlyKey === undefined) return [];
|
||||
const rec = records.get(onlyKey);
|
||||
return rec ? rec.history.map((e) => ({ ...e })) : [];
|
||||
},
|
||||
|
||||
close(): void {
|
||||
// no-op for in-memory store
|
||||
},
|
||||
};
|
||||
|
||||
return varStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory `TagStore`. Exposed for composition with non-Memory CAS
|
||||
* stores.
|
||||
*/
|
||||
export function createMemoryTagStoreImpl(): TagStore {
|
||||
// target -> key -> Tag
|
||||
const byTarget = new Map<Hash, Map<string, Tag>>();
|
||||
// key -> set of targets
|
||||
const byKey = new Map<string, Set<Hash>>();
|
||||
// per-target ordering (created)
|
||||
const targetOrder = new Map<Hash, number>();
|
||||
|
||||
function addKeyIndex(key: string, target: Hash): void {
|
||||
let set = byKey.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
byKey.set(key, set);
|
||||
}
|
||||
set.add(target);
|
||||
}
|
||||
|
||||
function removeKeyIndex(key: string, target: Hash): void {
|
||||
const set = byKey.get(key);
|
||||
if (!set) return;
|
||||
// only remove if this target no longer has that key in any tag
|
||||
const tmap = byTarget.get(target);
|
||||
if (tmap && tmap.has(key)) return;
|
||||
set.delete(target);
|
||||
if (set.size === 0) byKey.delete(key);
|
||||
}
|
||||
|
||||
return {
|
||||
tag(target: Hash, operations: TagOp[]): Tag[] {
|
||||
let tmap = byTarget.get(target);
|
||||
if (!tmap) {
|
||||
tmap = new Map();
|
||||
byTarget.set(target, tmap);
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const op of operations) {
|
||||
if (op.op === "set") {
|
||||
const tag: Tag = {
|
||||
key: op.key,
|
||||
value: op.value ?? null,
|
||||
target,
|
||||
created: tmap.get(op.key)?.created ?? now,
|
||||
};
|
||||
tmap.set(op.key, tag);
|
||||
addKeyIndex(op.key, target);
|
||||
} else {
|
||||
tmap.delete(op.key);
|
||||
removeKeyIndex(op.key, target);
|
||||
}
|
||||
}
|
||||
if (!targetOrder.has(target)) {
|
||||
targetOrder.set(target, now);
|
||||
}
|
||||
// return the current tags
|
||||
return [...tmap.values()].sort((a, b) =>
|
||||
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
|
||||
);
|
||||
},
|
||||
|
||||
untag(target: Hash, keys: string[]): void {
|
||||
const tmap = byTarget.get(target);
|
||||
if (!tmap) return;
|
||||
for (const k of keys) {
|
||||
tmap.delete(k);
|
||||
removeKeyIndex(k, target);
|
||||
}
|
||||
if (tmap.size === 0) {
|
||||
byTarget.delete(target);
|
||||
targetOrder.delete(target);
|
||||
}
|
||||
},
|
||||
|
||||
tags(target: Hash): Tag[] {
|
||||
const tmap = byTarget.get(target);
|
||||
if (!tmap) return [];
|
||||
return [...tmap.values()].sort((a, b) =>
|
||||
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
|
||||
);
|
||||
},
|
||||
|
||||
listByTag(tag: string, options?: ListOptions): Hash[] {
|
||||
// accept "key" or "key=value" form
|
||||
let key = tag;
|
||||
let value: string | null | undefined;
|
||||
const eqIdx = tag.indexOf("=");
|
||||
if (eqIdx >= 0) {
|
||||
key = tag.slice(0, eqIdx);
|
||||
value = tag.slice(eqIdx + 1);
|
||||
}
|
||||
const targets = byKey.get(key);
|
||||
if (!targets) return [];
|
||||
let entries: ListEntry[] = [];
|
||||
for (const t of targets) {
|
||||
const tmap = byTarget.get(t);
|
||||
if (!tmap) continue;
|
||||
const tagEntry = tmap.get(key);
|
||||
if (!tagEntry) continue;
|
||||
if (value !== undefined && tagEntry.value !== value) continue;
|
||||
entries.push(casListEntry(t, tagEntry.created));
|
||||
}
|
||||
entries = applyListOptions(entries, options);
|
||||
return entries.map((e) => e.hash);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an in-memory `Store` with three sub-stores: `cas`, `var`, `tag`.
|
||||
*
|
||||
* The `cas` sub-store also satisfies the legacy `BootstrapCapableStore`
|
||||
* contract — it carries a `[BOOTSTRAP_STORE]` callable and a `listAll()`
|
||||
* helper — so existing helpers (`bootstrap`, `gc`, `render`, …) can be
|
||||
* called with `store.cas` until they are migrated to the unified surface.
|
||||
*/
|
||||
export function createMemoryStore(): Store & {
|
||||
cas: MemoryCasStore;
|
||||
} {
|
||||
const cas = createCasStore();
|
||||
const varStore = createMemoryVarStoreFor(cas);
|
||||
const tagStore = createMemoryTagStoreImpl();
|
||||
return { cas, var: varStore, tag: tagStore };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
const T1 = "AAAAAAAAAAAAA";
|
||||
const T2 = "BBBBBBBBBBBBB";
|
||||
|
||||
describe("In-memory TagStore", () => {
|
||||
test("D1. tag set with key/value round-trip", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
const result = tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.key).toBe("env");
|
||||
expect(result[0]?.value).toBe("prod");
|
||||
expect(result[0]?.target).toBe(T1);
|
||||
expect(typeof result[0]?.created).toBe("number");
|
||||
expect(tag.tags(T1)).toEqual(result);
|
||||
});
|
||||
|
||||
test("D2. label tag (value omitted) records value: null", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
tag.tag(T1, [{ op: "set", key: "pinned" }]);
|
||||
const tags = tag.tags(T1);
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]?.key).toBe("pinned");
|
||||
expect(tags[0]?.value).toBeNull();
|
||||
});
|
||||
|
||||
test("D3. multiple ops in one call, sorted by key", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
const result = tag.tag(T1, [
|
||||
{ op: "set", key: "b", value: "2" },
|
||||
{ op: "set", key: "a", value: "1" },
|
||||
]);
|
||||
expect(result.map((t) => t.key)).toEqual(["a", "b"]);
|
||||
expect(tag.tags(T1).map((t) => t.key)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("D4. update existing key overwrites value", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
|
||||
tag.tag(T1, [{ op: "set", key: "env", value: "dev" }]);
|
||||
const tags = tag.tags(T1);
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]?.value).toBe("dev");
|
||||
});
|
||||
|
||||
test("D5. delete via tag op removes the entry", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
|
||||
tag.tag(T1, [{ op: "delete", key: "env" }]);
|
||||
expect(tag.tags(T1)).toEqual([]);
|
||||
});
|
||||
|
||||
test("D6. untag removes listed keys; missing keys silently skipped", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
tag.tag(T1, [
|
||||
{ op: "set", key: "a", value: "1" },
|
||||
{ op: "set", key: "b", value: "2" },
|
||||
]);
|
||||
tag.untag(T1, ["a", "missing"]);
|
||||
expect(tag.tags(T1).map((t) => t.key)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
test("D7. listByTag returns all targets with the bare key", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
|
||||
tag.tag(T2, [{ op: "set", key: "env", value: "dev" }]);
|
||||
const listed = tag.listByTag("env").sort();
|
||||
expect(listed).toEqual([T1, T2].sort());
|
||||
});
|
||||
|
||||
test("D8. listByTag with key=value form filters by exact value", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
|
||||
tag.tag(T2, [{ op: "set", key: "env", value: "dev" }]);
|
||||
expect(tag.listByTag("env=prod")).toEqual([T1]);
|
||||
});
|
||||
|
||||
test("D9. ListOptions on listByTag (limit, offset, desc)", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
const targets: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const t = `${"C".repeat(12)}${i}`;
|
||||
targets.push(t);
|
||||
tag.tag(t, [{ op: "set", key: "k", value: String(i) }]);
|
||||
}
|
||||
expect(tag.listByTag("k", { limit: 2 })).toHaveLength(2);
|
||||
expect(tag.listByTag("k", { offset: 3 })).toHaveLength(2);
|
||||
const desc = tag.listByTag("k", { desc: true });
|
||||
expect(desc[0]).toBe(targets[targets.length - 1] as string);
|
||||
});
|
||||
|
||||
test("D10. different targets are independent", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
tag.tag(T1, [{ op: "set", key: "k", value: "1" }]);
|
||||
expect(tag.tags(T2)).toEqual([]);
|
||||
});
|
||||
|
||||
test("D11. each Tag returned has a created timestamp", () => {
|
||||
const { tag } = createMemoryStore();
|
||||
const before = Date.now();
|
||||
const result = tag.tag(T1, [{ op: "set", key: "k", value: "v" }]);
|
||||
const after = Date.now();
|
||||
expect(result[0]?.created).toBeGreaterThanOrEqual(before);
|
||||
expect(result[0]?.created).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type {
|
||||
CasNode,
|
||||
CasStore,
|
||||
Hash,
|
||||
HistoryEntry,
|
||||
ListEntry,
|
||||
ListOptions,
|
||||
Store,
|
||||
Tag,
|
||||
TagOp,
|
||||
TagStore,
|
||||
Variable,
|
||||
VarListOptions,
|
||||
VarSetOptions,
|
||||
VarStore,
|
||||
} from "./index.js";
|
||||
|
||||
describe("CasStore type", () => {
|
||||
test("has all required methods with correct signatures", () => {
|
||||
const stub: CasStore = {
|
||||
get: (_h: Hash): CasNode | null => null,
|
||||
put: (_t: Hash, _p: unknown): Hash => "0000000000000",
|
||||
has: (_h: Hash): boolean => false,
|
||||
delete: (_h: Hash): boolean => false,
|
||||
listByType: (_t: Hash, _o?: ListOptions): ListEntry[] => [],
|
||||
listMeta: (_o?: ListOptions): ListEntry[] => [],
|
||||
listSchemas: (_o?: ListOptions): ListEntry[] => [],
|
||||
};
|
||||
expect(typeof stub.get).toBe("function");
|
||||
expect(typeof stub.put).toBe("function");
|
||||
expect(typeof stub.has).toBe("function");
|
||||
expect(typeof stub.delete).toBe("function");
|
||||
expect(typeof stub.listByType).toBe("function");
|
||||
expect(typeof stub.listMeta).toBe("function");
|
||||
expect(typeof stub.listSchemas).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VarStore type", () => {
|
||||
test("VarStore shape", () => {
|
||||
const sample: Variable = {
|
||||
name: "@x/y",
|
||||
schema: "0",
|
||||
value: "0",
|
||||
created: 0,
|
||||
updated: 0,
|
||||
tags: {},
|
||||
labels: [],
|
||||
};
|
||||
const stub: VarStore = {
|
||||
set: (_n: string, _h: Hash, _o?: VarSetOptions): Variable => sample,
|
||||
get: (_n: string, _s?: Hash): Variable | null => null,
|
||||
remove: (_n: string, _s?: Hash): Variable[] => [],
|
||||
update: (_n: string, _h: Hash, _o?: VarSetOptions): Variable => sample,
|
||||
list: (_o?: VarListOptions): Variable[] => [],
|
||||
history: (_n: string, _s?: Hash): HistoryEntry[] => [],
|
||||
close: (): void => {},
|
||||
};
|
||||
expect(typeof stub.set).toBe("function");
|
||||
expect(typeof stub.get).toBe("function");
|
||||
expect(typeof stub.remove).toBe("function");
|
||||
expect(typeof stub.update).toBe("function");
|
||||
expect(typeof stub.list).toBe("function");
|
||||
expect(typeof stub.history).toBe("function");
|
||||
expect(typeof stub.close).toBe("function");
|
||||
});
|
||||
|
||||
test("VarSetOptions shape", () => {
|
||||
const opts: VarSetOptions = { tags: { k: "v" }, labels: ["l"] };
|
||||
expect(opts.tags?.k).toBe("v");
|
||||
});
|
||||
|
||||
test("VarListOptions extends ListOptions", () => {
|
||||
const opts: VarListOptions = {
|
||||
namePrefix: "@x/",
|
||||
exactName: "@x/y",
|
||||
schema: "0",
|
||||
tags: { k: "v" },
|
||||
labels: ["l"],
|
||||
sort: "created",
|
||||
desc: true,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
};
|
||||
expect(opts.namePrefix).toBe("@x/");
|
||||
});
|
||||
|
||||
test("HistoryEntry shape", () => {
|
||||
const e: HistoryEntry = { value: "0", position: 0, setAt: 0 };
|
||||
expect(e.position).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TagStore type", () => {
|
||||
test("TagStore shape", () => {
|
||||
const stub: TagStore = {
|
||||
tag: (_t: Hash, _ops: TagOp[]): Tag[] => [],
|
||||
untag: (_t: Hash, _k: string[]): void => {},
|
||||
tags: (_t: Hash): Tag[] => [],
|
||||
listByTag: (_t: string, _o?: ListOptions): Hash[] => [],
|
||||
};
|
||||
expect(typeof stub.tag).toBe("function");
|
||||
expect(typeof stub.untag).toBe("function");
|
||||
expect(typeof stub.tags).toBe("function");
|
||||
expect(typeof stub.listByTag).toBe("function");
|
||||
});
|
||||
|
||||
test("Tag and TagOp shapes", () => {
|
||||
const t: Tag = { key: "k", value: "v", target: "0", created: 0 };
|
||||
const tNull: Tag = { key: "label", value: null, target: "0", created: 0 };
|
||||
const op1: TagOp = { op: "set", key: "k", value: "v" };
|
||||
const op2: TagOp = { op: "delete", key: "k" };
|
||||
expect(t.value).toBe("v");
|
||||
expect(tNull.value).toBeNull();
|
||||
expect(op1.op).toBe("set");
|
||||
expect(op2.op).toBe("delete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Aggregate Store type", () => {
|
||||
test("has cas, var, tag fields", () => {
|
||||
type _AssertCas = Store["cas"] extends CasStore ? true : false;
|
||||
type _AssertVar = Store["var"] extends VarStore ? true : false;
|
||||
type _AssertTag = Store["tag"] extends TagStore ? true : false;
|
||||
const a: _AssertCas = true;
|
||||
const b: _AssertVar = true;
|
||||
const c: _AssertTag = true;
|
||||
expect(a && b && c).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("source convention", () => {
|
||||
test("types.ts uses 'type' not 'interface' for new types", () => {
|
||||
const src = readFileSync(join(import.meta.dirname, "types.ts"), "utf8");
|
||||
for (const name of ["CasStore", "VarStore", "TagStore"]) {
|
||||
expect(src).toMatch(new RegExp(`export\\s+type\\s+${name}\\b`));
|
||||
expect(src).not.toMatch(new RegExp(`interface\\s+${name}\\b`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Exports surface", () => {
|
||||
test("@ocas/core exports the new type names (compile-time only)", () => {
|
||||
type _C = CasStore extends object ? true : never;
|
||||
type _V = VarStore extends object ? true : never;
|
||||
type _T = TagStore extends object ? true : never;
|
||||
type _O = Store extends object ? true : never;
|
||||
const _checks: [_C, _V, _T, _O] = [true, true, true, true];
|
||||
expect(_checks.length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
/**
|
||||
* 13-character uppercase Crockford Base32 string produced by XXH64.
|
||||
*/
|
||||
@@ -45,16 +47,95 @@ export type ListEntry = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Content-addressable store interface.
|
||||
* Self-referencing nodes are created only via bootstrap().
|
||||
* Synchronous content-addressable store interface.
|
||||
*/
|
||||
export type Store = {
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash>;
|
||||
export type CasStore = {
|
||||
get(hash: Hash): CasNode | null;
|
||||
put(typeHash: Hash, payload: unknown): Hash;
|
||||
has(hash: Hash): boolean;
|
||||
delete(hash: Hash): boolean;
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[];
|
||||
listAll(): Hash[];
|
||||
listMeta(options?: ListOptions): ListEntry[];
|
||||
listSchemas(options?: ListOptions): ListEntry[];
|
||||
delete(hash: Hash): void;
|
||||
listAll(): Hash[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for setting/updating a variable.
|
||||
*/
|
||||
export type VarSetOptions = {
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for listing variables.
|
||||
*/
|
||||
export type VarListOptions = ListOptions & {
|
||||
namePrefix?: string;
|
||||
exactName?: string;
|
||||
schema?: Hash;
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* One entry in a variable's history (most-recent-first per `position`).
|
||||
*/
|
||||
export type HistoryEntry = {
|
||||
value: Hash;
|
||||
position: number;
|
||||
setAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Variable store interface — mutable bindings (name, schema) → hash.
|
||||
*/
|
||||
export type VarStore = {
|
||||
set(name: string, hash: Hash, options?: VarSetOptions): Variable;
|
||||
get(name: string, schema?: Hash): Variable | null;
|
||||
remove(name: string, schema?: Hash): Variable[];
|
||||
update(name: string, hash: Hash, options?: VarSetOptions): Variable;
|
||||
list(options?: VarListOptions): Variable[];
|
||||
history(name: string, schema?: Hash): HistoryEntry[];
|
||||
close(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A tag attached to a CAS target. `value === null` indicates a label
|
||||
* (bare identifier); otherwise a key-value tag.
|
||||
*/
|
||||
export type Tag = {
|
||||
key: string;
|
||||
value: string | null;
|
||||
target: Hash;
|
||||
created: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A tag mutation operation: set (with value) or delete (key only).
|
||||
*/
|
||||
export type TagOp = {
|
||||
op: "set" | "delete";
|
||||
key: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tag store interface — manages key-value tags and labels on CAS targets.
|
||||
*/
|
||||
export type TagStore = {
|
||||
tag(target: Hash, operations: TagOp[]): Tag[];
|
||||
untag(target: Hash, keys: string[]): void;
|
||||
tags(target: Hash): Tag[];
|
||||
listByTag(tag: string, options?: ListOptions): Hash[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate OCAS store: bundles CAS, variable, and tag stores.
|
||||
*/
|
||||
export type Store = {
|
||||
cas: CasStore;
|
||||
var: VarStore;
|
||||
tag: TagStore;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { InvalidVariableNameError } from "./errors.js";
|
||||
|
||||
/**
|
||||
* Validate that a variable name follows the `@scope/name` format.
|
||||
*
|
||||
* Rules:
|
||||
* - Must start with `@<scope>/` where scope is `[a-zA-Z][a-zA-Z0-9]*`
|
||||
* - Must have at least one segment after the scope
|
||||
* - Each segment may contain only `[a-zA-Z0-9._-]`
|
||||
* - No empty segments (consecutive slashes) or trailing slash
|
||||
*
|
||||
* Note: this function does NOT enforce reservation of the `@ocas/*` scope —
|
||||
* that is enforced at the CLI / bootstrap layer.
|
||||
*
|
||||
* @throws InvalidVariableNameError when name is malformed
|
||||
*/
|
||||
export function validateName(name: string): void {
|
||||
if (name === "") {
|
||||
throw new InvalidVariableNameError(name, "Name cannot be empty");
|
||||
}
|
||||
const match = name.match(/^@([a-zA-Z][a-zA-Z0-9]*)\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name must follow @scope/name format (e.g. @myapp/config)",
|
||||
);
|
||||
}
|
||||
const rest = match[2] as string;
|
||||
if (rest.endsWith("/")) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot end with trailing slash",
|
||||
);
|
||||
}
|
||||
for (const segment of rest.split("/")) {
|
||||
if (segment === "") {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name contains empty segment (consecutive slashes //)",
|
||||
);
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
CasNodeNotFoundError,
|
||||
MAX_HISTORY,
|
||||
TagLabelConflictError,
|
||||
} from "./errors.js";
|
||||
import type { CasStore, Hash, HistoryEntry } from "./types.js";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
/**
|
||||
* Internal record shape used by both Memory and Fs VarStore implementations.
|
||||
* Persistence-specific code lives in each factory; pure operations on this
|
||||
* shape live here so they aren't duplicated.
|
||||
*/
|
||||
export type VarRecord = {
|
||||
name: string;
|
||||
schema: Hash;
|
||||
value: Hash;
|
||||
created: number;
|
||||
updated: number;
|
||||
tags: Record<string, string>;
|
||||
labels: string[];
|
||||
history: HistoryEntry[];
|
||||
};
|
||||
|
||||
/** Build the composite map key from `(name, schema)`. */
|
||||
export function varKey(name: string, schema: Hash): string {
|
||||
return `${name}\u0000${schema}`;
|
||||
}
|
||||
|
||||
/** Add `k` to the `byName[name]` index, creating the set if needed. */
|
||||
export function addNameIndex(
|
||||
byName: Map<string, Set<string>>,
|
||||
name: string,
|
||||
k: string,
|
||||
): void {
|
||||
let set = byName.get(name);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
byName.set(name, set);
|
||||
}
|
||||
set.add(k);
|
||||
}
|
||||
|
||||
/** Remove `k` from the `byName[name]` index, dropping empty sets. */
|
||||
export function removeNameIndex(
|
||||
byName: Map<string, Set<string>>,
|
||||
name: string,
|
||||
k: string,
|
||||
): void {
|
||||
const set = byName.get(name);
|
||||
if (!set) return;
|
||||
set.delete(k);
|
||||
if (set.size === 0) byName.delete(name);
|
||||
}
|
||||
|
||||
/** Resolve `hash → schema` (the type field of the stored CAS node). */
|
||||
export function extractSchema(cas: CasStore, hash: Hash): Hash {
|
||||
const node = cas.get(hash);
|
||||
if (node === null) throw new CasNodeNotFoundError(hash);
|
||||
return node.type;
|
||||
}
|
||||
|
||||
/** Reject `(tags, labels)` combinations where the same key appears in both. */
|
||||
export function checkTagLabelConflict(
|
||||
tags: Record<string, string>,
|
||||
labels: string[],
|
||||
): void {
|
||||
for (const tk of Object.keys(tags)) {
|
||||
if (labels.includes(tk)) {
|
||||
throw new TagLabelConflictError(tk, "label", "tag");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a new value onto the variable's history. Returns true if the head
|
||||
* changed (i.e. a new value was set), false when the head was already `value`.
|
||||
*/
|
||||
export function pushHistory(rec: VarRecord, value: Hash, now: number): boolean {
|
||||
if (rec.history.length > 0 && rec.history[0]?.value === value) return false;
|
||||
const existingIdx = rec.history.findIndex((e) => e.value === value);
|
||||
if (existingIdx > 0) rec.history.splice(existingIdx, 1);
|
||||
rec.history.unshift({ value, position: 0, setAt: now });
|
||||
if (rec.history.length > MAX_HISTORY) rec.history.length = MAX_HISTORY;
|
||||
for (let i = 0; i < rec.history.length; i++) {
|
||||
const entry = rec.history[i];
|
||||
if (entry !== undefined) entry.position = i;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Convert a stored `VarRecord` to the public `Variable` shape (deep copy). */
|
||||
export function cloneVarRecord(rec: VarRecord): Variable {
|
||||
return {
|
||||
name: rec.name,
|
||||
schema: rec.schema,
|
||||
value: rec.value,
|
||||
created: rec.created,
|
||||
updated: rec.updated,
|
||||
tags: { ...rec.tags },
|
||||
labels: [...rec.labels],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
CasNodeNotFoundError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
} from "./errors.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Hash } from "./types.js";
|
||||
import { validateName } from "./validation.js";
|
||||
|
||||
function makeStoreWithSchema(): {
|
||||
store: ReturnType<typeof createMemoryStore>;
|
||||
schema: Hash;
|
||||
meta: Hash;
|
||||
put: (payload: unknown) => Hash;
|
||||
} {
|
||||
const store = createMemoryStore();
|
||||
const meta = store.cas[Symbol.for("@ocas/core/bootstrap-store")]({
|
||||
type: "object",
|
||||
}) as Hash;
|
||||
const schema = store.cas.put(meta, { type: "string" });
|
||||
return {
|
||||
store,
|
||||
schema,
|
||||
meta,
|
||||
put: (payload) => store.cas.put(schema, payload),
|
||||
};
|
||||
}
|
||||
|
||||
describe("In-memory VarStore", () => {
|
||||
test("C1. set + get round-trip", () => {
|
||||
const { store, schema, put } = makeStoreWithSchema();
|
||||
const h = put("hello");
|
||||
const v = store.var.set("@app/x", h);
|
||||
expect(v.name).toBe("@app/x");
|
||||
expect(v.value).toBe(h);
|
||||
expect(v.schema).toBe(schema);
|
||||
expect(typeof v.created).toBe("number");
|
||||
expect(typeof v.updated).toBe("number");
|
||||
expect(v.tags).toEqual({});
|
||||
expect(v.labels).toEqual([]);
|
||||
|
||||
const got = store.var.get("@app/x", schema);
|
||||
expect(got).not.toBeNull();
|
||||
expect(got?.value).toBe(h);
|
||||
expect(got?.schema).toBe(schema);
|
||||
});
|
||||
|
||||
test("C2. name validation", () => {
|
||||
const { store, put } = makeStoreWithSchema();
|
||||
const h = put("v");
|
||||
expect(() => store.var.set("x", h)).toThrow(InvalidVariableNameError);
|
||||
expect(() => store.var.set("@/x", h)).toThrow(InvalidVariableNameError);
|
||||
expect(() => store.var.set("@app/", h)).toThrow(InvalidVariableNameError);
|
||||
expect(() => store.var.set("@app//x", h)).toThrow(InvalidVariableNameError);
|
||||
expect(() => store.var.set("@app/x.y_z-1", h)).not.toThrow();
|
||||
});
|
||||
|
||||
test("C3. set throws CasNodeNotFoundError if hash not in cas", () => {
|
||||
const { store } = makeStoreWithSchema();
|
||||
expect(() => store.var.set("@app/x", "ZZZZZZZZZZZZZ")).toThrow(
|
||||
CasNodeNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
test("C4. idempotent same-value set", async () => {
|
||||
const { store, schema, put } = makeStoreWithSchema();
|
||||
const h = put("v");
|
||||
const v1 = store.var.set("@app/x", h);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const v2 = store.var.set("@app/x", h);
|
||||
expect(v2.updated).toBe(v1.updated);
|
||||
expect(store.var.history("@app/x", schema)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("C5. update via re-set with new value bumps updated and history", async () => {
|
||||
const { store, schema, put } = makeStoreWithSchema();
|
||||
const h1 = put("v1");
|
||||
const h2 = put("v2");
|
||||
const v1 = store.var.set("@app/x", h1);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const v2 = store.var.set("@app/x", h2);
|
||||
expect(v2.updated).toBeGreaterThan(v1.updated);
|
||||
const hist = store.var.history("@app/x", schema);
|
||||
expect(hist.map((e) => e.value)).toEqual([h2, h1]);
|
||||
expect(hist[0]?.position).toBe(0);
|
||||
expect(hist[1]?.position).toBe(1);
|
||||
});
|
||||
|
||||
test("C6. history bound to MAX_HISTORY (10)", () => {
|
||||
const { store, schema, put } = makeStoreWithSchema();
|
||||
const hashes: Hash[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const h = put(`v${i}`);
|
||||
hashes.push(h);
|
||||
store.var.set("@app/x", h);
|
||||
}
|
||||
const hist = store.var.history("@app/x", schema);
|
||||
expect(hist).toHaveLength(MAX_HISTORY);
|
||||
expect(hist[0]?.value).toBe(hashes[11] as string);
|
||||
});
|
||||
|
||||
test("C7. history rotation when re-setting an older value", () => {
|
||||
const { store, schema, put } = makeStoreWithSchema();
|
||||
const h1 = put("v1");
|
||||
const h2 = put("v2");
|
||||
const h3 = put("v3");
|
||||
store.var.set("@app/x", h1);
|
||||
store.var.set("@app/x", h2);
|
||||
store.var.set("@app/x", h3);
|
||||
expect(store.var.history("@app/x", schema).map((e) => e.value)).toEqual([
|
||||
h3,
|
||||
h2,
|
||||
h1,
|
||||
]);
|
||||
store.var.set("@app/x", h1);
|
||||
expect(store.var.history("@app/x", schema).map((e) => e.value)).toEqual([
|
||||
h1,
|
||||
h3,
|
||||
h2,
|
||||
]);
|
||||
});
|
||||
|
||||
test("C8. tags + labels round-trip", () => {
|
||||
const { store, schema, put } = makeStoreWithSchema();
|
||||
const h = put("v");
|
||||
store.var.set("@app/x", h, {
|
||||
tags: { env: "prod" },
|
||||
labels: ["pinned"],
|
||||
});
|
||||
const got = store.var.get("@app/x", schema);
|
||||
expect(got?.tags).toEqual({ env: "prod" });
|
||||
expect(got?.labels).toEqual(["pinned"]);
|
||||
});
|
||||
|
||||
test("C9. tag/label conflict throws TagLabelConflictError", () => {
|
||||
const { store, put } = makeStoreWithSchema();
|
||||
const h = put("v");
|
||||
expect(() =>
|
||||
store.var.set("@app/x", h, {
|
||||
tags: { x: "y" },
|
||||
labels: ["x"],
|
||||
}),
|
||||
).toThrow(TagLabelConflictError);
|
||||
});
|
||||
|
||||
test("C10. update requires existing variable and matching schema", () => {
|
||||
const { store, put } = makeStoreWithSchema();
|
||||
const h = put("v");
|
||||
expect(() => store.var.update("@app/x", h)).toThrow(VariableNotFoundError);
|
||||
store.var.set("@app/x", h);
|
||||
|
||||
// Different schema for new value
|
||||
const meta = store.cas[Symbol.for("@ocas/core/bootstrap-store")]({
|
||||
type: "object",
|
||||
}) as Hash;
|
||||
const otherSchema = store.cas.put(meta, { type: "number" });
|
||||
const h2 = store.cas.put(otherSchema, 42);
|
||||
expect(() => store.var.update("@app/x", h2)).toThrow(SchemaMismatchError);
|
||||
});
|
||||
|
||||
test("C11. remove with and without schema", () => {
|
||||
const { store, schema, put } = makeStoreWithSchema();
|
||||
const h = put("v");
|
||||
store.var.set("@app/x", h);
|
||||
const removed = store.var.remove("@app/x", schema);
|
||||
expect(removed).toHaveLength(1);
|
||||
expect(removed[0]?.value).toBe(h);
|
||||
expect(store.var.get("@app/x", schema)).toBeNull();
|
||||
|
||||
// remove(name) without schema returns array; empty if none
|
||||
expect(store.var.remove("@app/none")).toEqual([]);
|
||||
|
||||
store.var.set("@app/y", put("y1"));
|
||||
const removedAll = store.var.remove("@app/y");
|
||||
expect(Array.isArray(removedAll)).toBe(true);
|
||||
expect(removedAll).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("C12. list filters and ListOptions", () => {
|
||||
const { store, schema, put } = makeStoreWithSchema();
|
||||
const h1 = put("v1");
|
||||
const h2 = put("v2");
|
||||
store.var.set("@app/a", h1, { tags: { env: "prod" } });
|
||||
store.var.set("@app/b", h2, { labels: ["pinned"] });
|
||||
|
||||
expect(() =>
|
||||
store.var.list({ namePrefix: "@app", exactName: "@app/a" }),
|
||||
).toThrow();
|
||||
|
||||
const byPrefix = store.var.list({ namePrefix: "@app/" });
|
||||
expect(byPrefix.map((v) => v.name).sort()).toEqual(["@app/a", "@app/b"]);
|
||||
|
||||
const exact = store.var.list({ exactName: "@app/a" });
|
||||
expect(exact).toHaveLength(1);
|
||||
|
||||
const byTag = store.var.list({ tags: { env: "prod" } });
|
||||
expect(byTag.map((v) => v.name)).toEqual(["@app/a"]);
|
||||
|
||||
const byLabel = store.var.list({ labels: ["pinned"] });
|
||||
expect(byLabel.map((v) => v.name)).toEqual(["@app/b"]);
|
||||
|
||||
const bySchema = store.var.list({ schema });
|
||||
expect(bySchema).toHaveLength(2);
|
||||
|
||||
const limited = store.var.list({ limit: 1 });
|
||||
expect(limited).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("C13. close() is a no-op", () => {
|
||||
const { store } = makeStoreWithSchema();
|
||||
expect(() => store.var.close()).not.toThrow();
|
||||
});
|
||||
|
||||
test("C14. cas.delete does NOT cascade-delete the variable", () => {
|
||||
const { store, schema, put } = makeStoreWithSchema();
|
||||
const h = put("v");
|
||||
store.var.set("@app/x", h);
|
||||
store.cas.delete(h);
|
||||
const got = store.var.get("@app/x", schema);
|
||||
expect(got).not.toBeNull();
|
||||
expect(got?.value).toBe(h);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateName (shared)", () => {
|
||||
test("C-VN1. accepts well-formed names", () => {
|
||||
expect(() => validateName("@app/x")).not.toThrow();
|
||||
expect(() => validateName("@app/a.b_c-1")).not.toThrow();
|
||||
expect(() => validateName("@app/nested/path")).not.toThrow();
|
||||
expect(() => validateName("@ocas/schema")).not.toThrow();
|
||||
});
|
||||
|
||||
test("C-VN2. rejects empty / missing-@ / @ -only / trailing slash / double slash", () => {
|
||||
expect(() => validateName("")).toThrow(InvalidVariableNameError);
|
||||
expect(() => validateName("x")).toThrow(InvalidVariableNameError);
|
||||
expect(() => validateName("@/x")).toThrow(InvalidVariableNameError);
|
||||
expect(() => validateName("@app/")).toThrow(InvalidVariableNameError);
|
||||
expect(() => validateName("@app//x")).toThrow(InvalidVariableNameError);
|
||||
});
|
||||
|
||||
test("C-VN3. rejects invalid segment characters", () => {
|
||||
expect(() => validateName("@app/foo bar")).toThrow(
|
||||
InvalidVariableNameError,
|
||||
);
|
||||
expect(() => validateName("@app/foo!bar")).toThrow(
|
||||
InvalidVariableNameError,
|
||||
);
|
||||
});
|
||||
|
||||
test("C-VN4. scope must start with a letter", () => {
|
||||
expect(() => validateName("@1bad/x")).toThrow(InvalidVariableNameError);
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
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,884 +0,0 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { Hash, ListSort, Store } from "./types.js";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
/**
|
||||
* Maximum number of historical values retained per (variable_name, variable_schema).
|
||||
* Position 0 is current; positions 1..MAX_HISTORY-1 are previous values (LRU).
|
||||
*/
|
||||
export const MAX_HISTORY = 10;
|
||||
|
||||
/**
|
||||
* Custom error types for variable operations
|
||||
*/
|
||||
export class VariableNotFoundError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public variableSchema: Hash,
|
||||
) {
|
||||
super(`Variable not found: name=${variableName}, schema=${variableSchema}`);
|
||||
this.name = "VariableNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidVariableNameError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public reason: string,
|
||||
) {
|
||||
super(`Invalid variable name "${variableName}": ${reason}`);
|
||||
this.name = "InvalidVariableNameError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SchemaMismatchError extends Error {
|
||||
constructor(
|
||||
public expected: string,
|
||||
public actual: string,
|
||||
) {
|
||||
super(`Schema mismatch: expected ${expected}, got ${actual}`);
|
||||
this.name = "SchemaMismatchError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CasNodeNotFoundError extends Error {
|
||||
constructor(
|
||||
public readonly hash: string,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `CAS node not found: ${hash}`);
|
||||
this.name = "CasNodeNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TagLabelConflictError extends Error {
|
||||
constructor(
|
||||
public conflictName: string,
|
||||
public existingType: "tag" | "label",
|
||||
public attemptedType: "tag" | "label",
|
||||
) {
|
||||
super(`Conflict: '${conflictName}' already exists as a ${existingType}`);
|
||||
this.name = "TagLabelConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTagFormatError extends Error {
|
||||
constructor(tag: string) {
|
||||
super(`Invalid tag format: ${tag}`);
|
||||
this.name = "InvalidTagFormatError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable store with SQLite backend
|
||||
*/
|
||||
export class VariableStore {
|
||||
private db: Database;
|
||||
|
||||
constructor(
|
||||
dbPath: string,
|
||||
private casStore: Store,
|
||||
) {
|
||||
this.db = new Database(dbPath, { create: true });
|
||||
// Enable foreign keys
|
||||
this.db.exec("PRAGMA foreign_keys = ON");
|
||||
this.initDb();
|
||||
}
|
||||
|
||||
private initDb(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS variables (
|
||||
name TEXT NOT NULL,
|
||||
schema TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created INTEGER NOT NULL,
|
||||
updated INTEGER NOT NULL,
|
||||
PRIMARY KEY (name, schema)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_var_name ON variables(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variable_tags (
|
||||
variable_name TEXT NOT NULL,
|
||||
variable_schema TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (variable_name, variable_schema, key),
|
||||
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variable_labels (
|
||||
variable_name TEXT NOT NULL,
|
||||
variable_schema TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (variable_name, variable_schema, name),
|
||||
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_tag_key_value ON variable_tags(key, value);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_label_name ON variable_labels(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variable_history (
|
||||
variable_name TEXT NOT NULL,
|
||||
variable_schema TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
set_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (variable_name, variable_schema, position),
|
||||
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_var_history_value ON variable_history(value);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (name === "") {
|
||||
throw new InvalidVariableNameError(name, "Name cannot be empty");
|
||||
}
|
||||
|
||||
// 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 must follow @scope/name format (e.g. @myapp/config)",
|
||||
);
|
||||
}
|
||||
|
||||
const rest = match[2] as string;
|
||||
|
||||
// Validate remaining segments
|
||||
if (rest.endsWith("/")) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot end with trailing slash",
|
||||
);
|
||||
}
|
||||
|
||||
const segments = rest.split("/");
|
||||
for (const segment of segments) {
|
||||
if (segment === "") {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name contains empty segment (consecutive slashes //)",
|
||||
);
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract schema hash from CAS node
|
||||
*/
|
||||
private extractSchema(hash: string): string {
|
||||
const node = this.casStore.get(hash);
|
||||
if (node === null) {
|
||||
throw new CasNodeNotFoundError(hash);
|
||||
}
|
||||
return node.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tags for a variable
|
||||
*/
|
||||
private loadTags(name: string, schema: Hash): Record<string, string> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT key, value
|
||||
FROM variable_tags
|
||||
WHERE variable_name = ? AND variable_schema = ?
|
||||
`);
|
||||
|
||||
const rows = stmt.all(name, schema) as Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
}>;
|
||||
const tags: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
tags[row.key] = row.value;
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load labels for a variable
|
||||
*/
|
||||
private loadLabels(name: string, schema: Hash): string[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT name
|
||||
FROM variable_labels
|
||||
WHERE variable_name = ? AND variable_schema = ?
|
||||
ORDER BY name ASC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(name, schema) as Array<{ name: string }>;
|
||||
return rows.map((row) => row.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage history for a variable on set().
|
||||
*
|
||||
* Rules:
|
||||
* - If new value equals current (position 0), no-op (idempotent).
|
||||
* - If new value already exists in history at position N, remove it; entries
|
||||
* with position < N shift +1; insert new value at position 0.
|
||||
* - Otherwise shift all entries +1, insert new at position 0, prune any
|
||||
* entries at position >= MAX_HISTORY.
|
||||
*
|
||||
* Caller must invoke inside a transaction.
|
||||
* Returns true if history changed (i.e. value differs from current),
|
||||
* false if it was a no-op.
|
||||
*/
|
||||
private recordHistory(
|
||||
name: string,
|
||||
schema: Hash,
|
||||
value: Hash,
|
||||
now: number,
|
||||
): boolean {
|
||||
// Check current value at position 0
|
||||
const currentRow = this.db
|
||||
.prepare(
|
||||
`SELECT value FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = 0`,
|
||||
)
|
||||
.get(name, schema) as { value: string } | undefined | null;
|
||||
|
||||
if (currentRow && currentRow.value === value) {
|
||||
// Idempotent: same value as current; do nothing
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find existing position of this value (if any)
|
||||
const existingRow = this.db
|
||||
.prepare(
|
||||
`SELECT position FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND value = ?`,
|
||||
)
|
||||
.get(name, schema, value) as { position: number } | undefined | null;
|
||||
|
||||
if (existingRow) {
|
||||
const existingPos = existingRow.position;
|
||||
// Delete the existing entry first to free its position
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = ?`,
|
||||
)
|
||||
.run(name, schema, existingPos);
|
||||
|
||||
// Shift positions [0, existingPos) up by 1.
|
||||
// Use a temporary offset to avoid PK conflicts during the shift.
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE variable_history SET position = position + 1000000 WHERE variable_name = ? AND variable_schema = ? AND position < ?`,
|
||||
)
|
||||
.run(name, schema, existingPos);
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE variable_history SET position = position - 1000000 + 1 WHERE variable_name = ? AND variable_schema = ? AND position >= 1000000`,
|
||||
)
|
||||
.run(name, schema);
|
||||
} else {
|
||||
// New value: shift everything +1 (using temp offset to avoid PK conflicts)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE variable_history SET position = position + 1000000 WHERE variable_name = ? AND variable_schema = ?`,
|
||||
)
|
||||
.run(name, schema);
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE variable_history SET position = position - 1000000 + 1 WHERE variable_name = ? AND variable_schema = ? AND position >= 1000000`,
|
||||
)
|
||||
.run(name, schema);
|
||||
|
||||
// Prune any entries that ended up at position >= MAX_HISTORY
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position >= ?`,
|
||||
)
|
||||
.run(name, schema, MAX_HISTORY);
|
||||
}
|
||||
|
||||
// Insert new value at position 0
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO variable_history (variable_name, variable_schema, value, position, set_at) VALUES (?, ?, ?, 0, ?)`,
|
||||
)
|
||||
.run(name, schema, value, now);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a variable (upsert: create or update)
|
||||
*/
|
||||
set(
|
||||
name: string,
|
||||
value: string,
|
||||
options?: {
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
},
|
||||
): Variable {
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const schema = this.extractSchema(value);
|
||||
|
||||
// Check if variable exists
|
||||
const existing = this.get(name, schema);
|
||||
|
||||
if (existing !== null) {
|
||||
// Update existing variable
|
||||
const now = Date.now();
|
||||
|
||||
// If options provided, use them; otherwise preserve existing
|
||||
const tags = options?.tags ?? existing.tags;
|
||||
const labels = options?.labels ?? existing.labels;
|
||||
|
||||
// Check for tag/label conflicts when updating with new options
|
||||
if (options !== undefined) {
|
||||
const tagKeys = Object.keys(tags);
|
||||
for (const key of tagKeys) {
|
||||
if (labels.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
let changed = false;
|
||||
try {
|
||||
// Manage history (also detects idempotent same-value sets)
|
||||
changed = this.recordHistory(name, schema, value, now);
|
||||
|
||||
// Update value and timestamp only if value changed
|
||||
if (changed) {
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE variables
|
||||
SET value = ?, updated = ?
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
updateStmt.run(value, now, name, schema);
|
||||
}
|
||||
|
||||
// If options provided, update tags/labels
|
||||
if (options !== undefined) {
|
||||
// Delete existing tags and labels
|
||||
this.db
|
||||
.prepare(`
|
||||
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ?
|
||||
`)
|
||||
.run(name, schema);
|
||||
|
||||
this.db
|
||||
.prepare(`
|
||||
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ?
|
||||
`)
|
||||
.run(name, schema);
|
||||
|
||||
// Insert new tags
|
||||
const tagKeys = Object.keys(tags);
|
||||
if (tagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, val] of Object.entries(tags)) {
|
||||
tagStmt.run(name, schema, key, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new labels
|
||||
if (labels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const labelName of labels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
created: existing.created,
|
||||
updated: changed ? now : existing.updated,
|
||||
tags,
|
||||
labels: [...labels],
|
||||
};
|
||||
}
|
||||
|
||||
// Create new variable
|
||||
const tags = options?.tags ?? {};
|
||||
const labels = options?.labels ?? [];
|
||||
|
||||
// Check for tag/label conflicts
|
||||
const tagKeys = Object.keys(tags);
|
||||
for (const key of tagKeys) {
|
||||
if (labels.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO variables (name, schema, value, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(name, schema, value, now, now);
|
||||
|
||||
// Initialise history with this value at position 0
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO variable_history (variable_name, variable_schema, value, position, set_at) VALUES (?, ?, ?, 0, ?)`,
|
||||
)
|
||||
.run(name, schema, value, now);
|
||||
|
||||
// Insert tags
|
||||
if (tagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, val] of Object.entries(tags)) {
|
||||
tagStmt.run(name, schema, key, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert labels
|
||||
if (labels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const labelName of labels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
created: now,
|
||||
updated: now,
|
||||
tags,
|
||||
labels: [...labels],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a variable by name, optionally with schema
|
||||
*/
|
||||
/**
|
||||
* Get a variable by name and schema
|
||||
* @param name - Variable name
|
||||
* @param schema - Schema hash (required)
|
||||
* @returns Variable if found, null otherwise
|
||||
*/
|
||||
get(name: string, schema: Hash): Variable | null {
|
||||
// Precise match with schema
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT name, schema, value, created, updated
|
||||
FROM variables
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
const row = stmt.get(name, schema) as
|
||||
| {
|
||||
name: string;
|
||||
schema: string;
|
||||
value: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
if (row === undefined || row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = this.loadTags(row.name, row.schema);
|
||||
const labels = this.loadLabels(row.name, row.schema);
|
||||
|
||||
return {
|
||||
name: row.name,
|
||||
schema: row.schema,
|
||||
value: row.value,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
tags,
|
||||
labels,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a variable's value (with schema validation)
|
||||
*/
|
||||
update(name: string, schema: Hash, value: string): Variable {
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const newSchema = this.extractSchema(value);
|
||||
if (newSchema !== existing.schema) {
|
||||
throw new SchemaMismatchError(existing.schema, newSchema);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE variables
|
||||
SET value = ?, updated = ?
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
stmt.run(value, now, name, schema);
|
||||
|
||||
return {
|
||||
...existing,
|
||||
value,
|
||||
updated: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a variable (or all variants if schema omitted)
|
||||
*/
|
||||
remove(name: string): Variable[];
|
||||
remove(name: string, schema: Hash): Variable;
|
||||
remove(name: string, schema?: Hash): Variable | Variable[] {
|
||||
if (schema !== undefined) {
|
||||
// Remove specific (name, schema) variant
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM variables WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
stmt.run(name, schema);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Remove all schema variants for this name
|
||||
const variants = this.list({
|
||||
exactName: name,
|
||||
});
|
||||
|
||||
if (variants.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM variables WHERE name = ?
|
||||
`);
|
||||
|
||||
stmt.run(name);
|
||||
|
||||
return variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* List variables with optional filters
|
||||
*/
|
||||
list(options?: {
|
||||
namePrefix?: string;
|
||||
exactName?: string;
|
||||
schema?: Hash;
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
sort?: ListSort;
|
||||
desc?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Variable[] {
|
||||
// Validate mutually exclusive options
|
||||
if (options?.namePrefix !== undefined && options?.exactName !== undefined) {
|
||||
throw new Error(
|
||||
"namePrefix and exactName are mutually exclusive - cannot specify both",
|
||||
);
|
||||
}
|
||||
|
||||
const namePrefix = options?.namePrefix ?? "";
|
||||
const exactName = options?.exactName;
|
||||
const schema = options?.schema;
|
||||
const filterTags = options?.tags ?? {};
|
||||
const filterLabels = options?.labels ?? [];
|
||||
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 = `
|
||||
SELECT DISTINCT v.name, v.schema, v.value, v.created, v.updated
|
||||
FROM variables v
|
||||
`;
|
||||
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
// Tag filters (AND logic)
|
||||
const tagKeys = Object.keys(filterTags);
|
||||
for (let i = 0; i < tagKeys.length; i++) {
|
||||
const key = tagKeys[i] as string;
|
||||
const value = filterTags[key] as string;
|
||||
query += `
|
||||
INNER JOIN variable_tags t${i} ON v.name = t${i}.variable_name
|
||||
AND v.schema = t${i}.variable_schema
|
||||
AND t${i}.key = ? AND t${i}.value = ?
|
||||
`;
|
||||
params.push(key, value);
|
||||
}
|
||||
|
||||
// Label filters (AND logic)
|
||||
for (let i = 0; i < filterLabels.length; i++) {
|
||||
const label = filterLabels[i] as string;
|
||||
query += `
|
||||
INNER JOIN variable_labels l${i} ON v.name = l${i}.variable_name
|
||||
AND v.schema = l${i}.variable_schema
|
||||
AND l${i}.name = ?
|
||||
`;
|
||||
params.push(label);
|
||||
}
|
||||
|
||||
// WHERE clause for name filters and schema
|
||||
const whereClauses: string[] = [];
|
||||
|
||||
if (exactName !== undefined) {
|
||||
whereClauses.push("v.name = ?");
|
||||
params.push(exactName);
|
||||
} else if (namePrefix !== "") {
|
||||
whereClauses.push("v.name LIKE ? || '%'");
|
||||
params.push(namePrefix);
|
||||
}
|
||||
|
||||
if (schema !== undefined) {
|
||||
whereClauses.push("v.schema = ?");
|
||||
params.push(schema);
|
||||
}
|
||||
|
||||
if (whereClauses.length > 0) {
|
||||
query += ` WHERE ${whereClauses.join(" AND ")}`;
|
||||
}
|
||||
|
||||
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<{
|
||||
name: string;
|
||||
schema: string;
|
||||
value: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
name: row.name,
|
||||
schema: row.schema,
|
||||
value: row.value,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
tags: this.loadTags(row.name, row.schema),
|
||||
labels: this.loadLabels(row.name, row.schema),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/update/delete tags and labels
|
||||
*/
|
||||
tag(
|
||||
name: string,
|
||||
schema: Hash,
|
||||
operations: {
|
||||
add?: Record<string, string>; // tags to add/update
|
||||
addLabels?: string[]; // labels to add
|
||||
delete?: string[]; // tag keys or label names to delete
|
||||
},
|
||||
): Variable {
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const addTags = operations.add ?? {};
|
||||
const addLabels = operations.addLabels ?? [];
|
||||
const deleteNames = operations.delete ?? [];
|
||||
|
||||
// Check for conflicts between tags and labels
|
||||
const newTagKeys = Object.keys(addTags);
|
||||
for (const key of newTagKeys) {
|
||||
// Check if this key is being added as a label in the same operation
|
||||
if (addLabels.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
// Check if this key already exists as a label (and not being deleted)
|
||||
if (existing.labels.includes(key) && !deleteNames.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
}
|
||||
|
||||
for (const labelName of addLabels) {
|
||||
// Check if this name is being added as a tag in the same operation
|
||||
if (newTagKeys.includes(labelName)) {
|
||||
throw new TagLabelConflictError(labelName, "tag", "label");
|
||||
}
|
||||
// Check if this name already exists as a tag key (and not being deleted)
|
||||
if (
|
||||
existing.tags[labelName] !== undefined &&
|
||||
!deleteNames.includes(labelName)
|
||||
) {
|
||||
throw new TagLabelConflictError(labelName, "tag", "label");
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
try {
|
||||
// Update timestamp
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE variables SET updated = ? WHERE name = ? AND schema = ?
|
||||
`);
|
||||
updateStmt.run(now, name, schema);
|
||||
|
||||
// Delete tags and labels
|
||||
if (deleteNames.length > 0) {
|
||||
const deleteTagStmt = this.db.prepare(`
|
||||
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ? AND key = ?
|
||||
`);
|
||||
const deleteLabelStmt = this.db.prepare(`
|
||||
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ? AND name = ?
|
||||
`);
|
||||
for (const deleteName of deleteNames) {
|
||||
deleteTagStmt.run(name, schema, deleteName);
|
||||
deleteLabelStmt.run(name, schema, deleteName);
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update tags
|
||||
if (newTagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, value] of Object.entries(addTags)) {
|
||||
tagStmt.run(name, schema, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels (with conflict handling)
|
||||
if (addLabels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const labelName of addLabels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Return updated variable
|
||||
const updated = this.get(name, schema);
|
||||
if (updated === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value history for a variable, ordered by position.
|
||||
* Index 0 is the current value; subsequent entries are older.
|
||||
* Returns an empty array if the variable does not exist.
|
||||
*/
|
||||
history(name: string, schema: Hash): Hash[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT value, position FROM variable_history WHERE variable_name = ? AND variable_schema = ? ORDER BY position ASC`,
|
||||
)
|
||||
.all(name, schema) as Array<{ value: string; position: number }>;
|
||||
return rows.map((r) => r.value as Hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a variable store
|
||||
*/
|
||||
export function createVariableStore(
|
||||
dbPath: string,
|
||||
casStore: Store,
|
||||
): VariableStore {
|
||||
return new VariableStore(dbPath, casStore);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
describe("Variable Type", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import { wrapEnvelope } from "./wrap-envelope.js";
|
||||
@@ -6,7 +6,7 @@ import { wrapEnvelope } from "./wrap-envelope.js";
|
||||
describe("wrapEnvelope", () => {
|
||||
test("resolves @ocas/output/put alias and returns envelope", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
|
||||
const envelope = await wrapEnvelope(
|
||||
store,
|
||||
@@ -20,7 +20,7 @@ describe("wrapEnvelope", () => {
|
||||
|
||||
test("resolves @ocas/output/has alias with boolean value", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
|
||||
const envelope = await wrapEnvelope(store, "@ocas/output/has", true);
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("wrapEnvelope", () => {
|
||||
|
||||
test("resolves @ocas/output/gc alias with object value", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
|
||||
const gcStats = { total: 100, reachable: 80, collected: 20, scanned: 5 };
|
||||
const envelope = await wrapEnvelope(store, "@ocas/output/gc", gcStats);
|
||||
@@ -39,9 +39,9 @@ describe("wrapEnvelope", () => {
|
||||
expect(envelope.value).toEqual(gcStats);
|
||||
});
|
||||
|
||||
test("resolves primitive alias @string", async () => {
|
||||
test("resolves primitive alias @ocas/string", async () => {
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
|
||||
const envelope = await wrapEnvelope(store, "@ocas/string", "hello");
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("wrapEnvelope", () => {
|
||||
|
||||
test("throws for unknown alias", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
await expect(
|
||||
wrapEnvelope(store, "@ocas/output/nonexistent", "value"),
|
||||
@@ -75,7 +75,7 @@ describe("wrapEnvelope", () => {
|
||||
|
||||
test("preserves complex object values without mutation", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const original = {
|
||||
name: "test",
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { Hash, Store } from "./types.js";
|
||||
|
||||
/**
|
||||
* Resolve a schema alias (e.g. "@ocas/output/put") to its hash via bootstrap,
|
||||
* then return a typed envelope ready for store.put() or direct rendering.
|
||||
* then return a typed envelope ready for store.cas.put() or direct rendering.
|
||||
*/
|
||||
export async function wrapEnvelope(
|
||||
store: Store,
|
||||
schemaAlias: string,
|
||||
value: unknown,
|
||||
): Promise<{ type: Hash; value: unknown }> {
|
||||
const aliases = await bootstrap(store);
|
||||
const aliases = bootstrap(store);
|
||||
const typeHash = aliases[schemaAlias];
|
||||
if (typeHash === undefined) {
|
||||
throw new Error(`Unknown schema alias: ${schemaAlias}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { bootstrap } from "../src/bootstrap.js";
|
||||
import { MemStore } from "../src/mem-store.js";
|
||||
import type { JSONSchema } from "../src/schema.js";
|
||||
@@ -15,7 +15,7 @@ import type { CasNode } from "../src/types.js";
|
||||
describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
test("1.1: Meta-schema is a valid JSON Schema", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.2: Meta-schema self-validates", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.3: Meta-schema defines all supported keywords", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.4: Meta-schema does not include unsupported keywords", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
|
||||
test("1.5: Meta-schema node type equals its own hash", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
@@ -110,22 +110,22 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
test("2.1: Accept minimal valid schema (empty object)", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {});
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.2: Accept schema with type constraint", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, { type: "string" });
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, { type: "string" });
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.3: Accept schema with properties", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
});
|
||||
@@ -134,8 +134,8 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
|
||||
test("2.4: Accept schema with required fields", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: { id: { type: "string" } },
|
||||
@@ -145,8 +145,8 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
|
||||
test("2.5: Accept schema with additionalProperties = false", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
});
|
||||
@@ -155,8 +155,8 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
|
||||
test("2.6: Accept schema with additionalProperties = schema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
});
|
||||
@@ -165,8 +165,8 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
|
||||
test("2.7: Accept schema with anyOf", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
@@ -174,8 +174,8 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
|
||||
test("2.7b: Accept schema with oneOf", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
oneOf: [
|
||||
{ properties: { status: { const: "ready" } }, required: ["status"] },
|
||||
{ properties: { status: { const: "failed" } }, required: ["status"] },
|
||||
@@ -186,8 +186,8 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
|
||||
test("2.8: Accept schema with array items", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
});
|
||||
@@ -196,8 +196,8 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
|
||||
test("2.9: Accept schema with format constraint", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
type: "string",
|
||||
format: "ocas_ref",
|
||||
});
|
||||
@@ -206,8 +206,8 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
|
||||
test("2.10: Accept schema with enum", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
type: "string",
|
||||
enum: ["red", "green", "blue"],
|
||||
});
|
||||
@@ -216,15 +216,15 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
|
||||
test("2.11: Accept schema with const", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, { const: "FIXED_VALUE" });
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, { const: "FIXED_VALUE" });
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.12: Accept schema with title and description", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
type: "string",
|
||||
title: "User Name",
|
||||
description: "The user's full name",
|
||||
@@ -234,8 +234,8 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
|
||||
test("2.13: Accept complex nested schema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
bootstrap(store);
|
||||
const hash = putSchema(store, {
|
||||
type: "object",
|
||||
required: ["type", "payload"],
|
||||
properties: {
|
||||
@@ -257,193 +257,181 @@ describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
test("3.1: Reject schema with invalid type value", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(async () => await putSchema(store, { type: "garbage" })).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, { type: "garbage" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.2: Reject schema with type as number", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: 123 } as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, { type: 123 } as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.3: Reject schema with properties not an object", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
properties: "not-an-object",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
type: "object",
|
||||
properties: "not-an-object",
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.4: Reject schema with required not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
required: "name",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
type: "object",
|
||||
required: "name",
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.5: Reject schema with required containing non-strings", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name", 123, true],
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name", 123, true],
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.6: Reject schema with additionalProperties as string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: "yes",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: "yes",
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.7: Reject schema with anyOf not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
anyOf: { type: "string" },
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
anyOf: { type: "string" },
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.8: Reject schema with empty anyOf array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(async () => await putSchema(store, { anyOf: [] })).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () => putSchema(store, { anyOf: [] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.9: Reject schema with items not an object", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "array",
|
||||
items: "string",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
type: "array",
|
||||
items: "string",
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.10: Reject schema with format not a string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
format: 123,
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
type: "string",
|
||||
format: 123,
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.11: Reject schema with enum not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
enum: "red",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
type: "string",
|
||||
enum: "red",
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.12: Reject schema with empty enum array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () => await putSchema(store, { type: "string", enum: [] }),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, { type: "string", enum: [] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.13: Reject schema with title not a string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
title: 123,
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
type: "string",
|
||||
title: 123,
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.14: Reject schema with description not a string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
description: ["not a string"],
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
type: "string",
|
||||
description: ["not a string"],
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.15: Reject schema with unsupported $ref keyword", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
$ref: "#/definitions/user",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
$ref: "#/definitions/user",
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.16: Reject completely invalid data (non-object)", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, "not-a-schema" as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, "not-a-schema" as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("3.17: Reject nested invalid schema in properties", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "invalid-type" },
|
||||
},
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
bootstrap(store);
|
||||
await expect(async () =>
|
||||
putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "invalid-type" },
|
||||
},
|
||||
} as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 4: Error Messages and Debugging", () => {
|
||||
test("4.1: Error includes schema validation details", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
try {
|
||||
await putSchema(store, { type: 123 } as unknown as JSONSchema);
|
||||
putSchema(store, { type: 123 } as unknown as JSONSchema);
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SchemaValidationError);
|
||||
@@ -453,9 +441,9 @@ describe("Test Suite 4: Error Messages and Debugging", () => {
|
||||
|
||||
test("4.2: Error distinguishes schema validation from data validation", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
try {
|
||||
await putSchema(store, { type: "invalid-type" } as unknown as JSONSchema);
|
||||
putSchema(store, { type: "invalid-type" } as unknown as JSONSchema);
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SchemaValidationError);
|
||||
@@ -468,7 +456,7 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
test("5.1: Bootstrap hash changes (breaking change)", async () => {
|
||||
// This is a documentation test - the old hash was different
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const newMetaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
// The new hash should be different from the old system metadata hash
|
||||
@@ -479,10 +467,10 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
test("5.2: Existing tests compatibility", async () => {
|
||||
// This test ensures our changes don't break existing valid schema usage
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
// This is the kind of schema that existed before
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
@@ -494,9 +482,9 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
|
||||
test("5.3: Data nodes with valid schemas still validate", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
@@ -512,9 +500,9 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
|
||||
test("5.4: Invalid data still fails validation", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
@@ -534,10 +522,10 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
test("6.1: getSchema works with validated schemas", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const originalSchema = { type: "string", title: "Test" };
|
||||
const schemaHash = await putSchema(store, originalSchema);
|
||||
const schemaHash = putSchema(store, originalSchema);
|
||||
const retrieved = getSchema(store, schemaHash);
|
||||
|
||||
expect(retrieved).toEqual(originalSchema);
|
||||
@@ -545,9 +533,9 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
|
||||
test("6.2: validate() works with schemas validated by meta-schema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, { type: "number" });
|
||||
const schemaHash = putSchema(store, { type: "number" });
|
||||
const validNode = store.get(await store.put(schemaHash, 42));
|
||||
const invalidNode = store.get(await store.put(schemaHash, "not a number"));
|
||||
|
||||
@@ -557,9 +545,9 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
|
||||
test("6.3: refs() works with validated schemas containing ocas_ref", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: { type: "string", format: "ocas_ref" },
|
||||
@@ -575,9 +563,9 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
|
||||
test("6.4: walk() works with graphs using validated schemas", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
const schemaHash = putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
next: {
|
||||
@@ -598,11 +586,11 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
|
||||
test("6.5: Idempotency preserved for putSchema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const schema = { type: "string", title: "Test" };
|
||||
const hash1 = await putSchema(store, schema);
|
||||
const hash2 = await putSchema(store, schema);
|
||||
const hash1 = putSchema(store, schema);
|
||||
const hash2 = putSchema(store, schema);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
@@ -611,7 +599,7 @@ describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||
test("7.1: Meta-schema allows recursive schema definitions", async () => {
|
||||
const store = new MemStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
@@ -624,11 +612,11 @@ describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||
|
||||
test("7.2: Meta-schema restricts additionalProperties", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
// Schema with unknown keyword should be rejected if meta-schema is strict
|
||||
try {
|
||||
await putSchema(store, {
|
||||
putSchema(store, {
|
||||
type: "string",
|
||||
unknownKeyword: "value",
|
||||
} as unknown as JSONSchema);
|
||||
@@ -642,30 +630,29 @@ describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||
|
||||
test("7.3: Meta-schema validates type as string OR array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
// Single string type
|
||||
const hash1 = await putSchema(store, { type: "string" });
|
||||
const hash1 = putSchema(store, { type: "string" });
|
||||
expect(hash1).toBeTruthy();
|
||||
|
||||
// Array of types
|
||||
const hash2 = await putSchema(store, {
|
||||
const hash2 = putSchema(store, {
|
||||
type: ["string", "null"],
|
||||
} as unknown as JSONSchema);
|
||||
expect(hash2).toBeTruthy();
|
||||
|
||||
// Invalid type (number)
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: 123 } as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
await expect(async () =>
|
||||
putSchema(store, { type: 123 } as unknown as JSONSchema),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 8: Performance and Edge Cases", () => {
|
||||
test("8.1: Validation performance is acceptable", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const complexSchema = {
|
||||
type: "object",
|
||||
@@ -686,7 +673,7 @@ describe("Test Suite 8: Performance and Edge Cases", () => {
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await putSchema(store, complexSchema);
|
||||
putSchema(store, complexSchema);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
@@ -696,7 +683,7 @@ describe("Test Suite 8: Performance and Edge Cases", () => {
|
||||
|
||||
test("8.2: Large schemas are handled correctly", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
const largeSchema: Record<string, unknown> = {
|
||||
type: "object",
|
||||
@@ -709,13 +696,13 @@ describe("Test Suite 8: Performance and Edge Cases", () => {
|
||||
props[`prop${i}`] = { type: "string" };
|
||||
}
|
||||
|
||||
const hash = await putSchema(store, largeSchema);
|
||||
const hash = putSchema(store, largeSchema);
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("8.3: Deeply nested schemas validate correctly", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
// Build a 5-level deep schema
|
||||
let schema: Record<string, unknown> = { type: "string" };
|
||||
@@ -726,13 +713,13 @@ describe("Test Suite 8: Performance and Edge Cases", () => {
|
||||
};
|
||||
}
|
||||
|
||||
const hash = await putSchema(store, schema);
|
||||
const hash = putSchema(store, schema);
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("8.4: Circular-like schemas don't cause infinite loops", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
bootstrap(store);
|
||||
|
||||
// Schema where additionalProperties has same structure as parent
|
||||
const schema = {
|
||||
@@ -748,7 +735,7 @@ describe("Test Suite 8: Performance and Edge Cases", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const hash = await putSchema(store, schema);
|
||||
const hash = putSchema(store, schema);
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
+26
-51
@@ -1,70 +1,45 @@
|
||||
# @uncaged/json-cas-fs
|
||||
# @ocas/fs
|
||||
|
||||
## 0.6.0
|
||||
## 0.4.0 — 2026-06-07
|
||||
|
||||
### Minor Changes
|
||||
- Lazy loading: `FsStore` scans only filenames in `nodes/` at startup (no CBOR decoding), reads each node from disk on first `get()`. Startup is O(filenames) instead of O(decoded-bytes).
|
||||
- Move CAS node files from store root into `nodes/` subdirectory. Pre-existing flat-layout stores are auto-migrated on first open.
|
||||
|
||||
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||
## 0.3.0 — 2026-06-03
|
||||
|
||||
### Breaking Changes
|
||||
- Migrate from `better-sqlite3` to built-in `node:sqlite` — zero native addon dependencies.
|
||||
|
||||
- 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
|
||||
## 0.2.2 — 2026-06-03
|
||||
|
||||
### New Features
|
||||
- Lint and format fixes.
|
||||
|
||||
- 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.2.1 — 2026-06-03
|
||||
|
||||
### Patch Changes
|
||||
- Migrate var/tag store from JSONL to `better-sqlite3`.
|
||||
- `openStore()` now returns unified `Store` with `cas`, `var`, `tag` sub-stores.
|
||||
- Migrate runtime from Bun to Node.js + pnpm.
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.6.0
|
||||
## 0.2.0 — 2026-06-02
|
||||
|
||||
## 0.5.3
|
||||
### Breaking Changes
|
||||
|
||||
### Patch Changes
|
||||
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`.
|
||||
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally.
|
||||
|
||||
- feat: add oneOf support to meta-schema validation
|
||||
### New Features
|
||||
|
||||
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.
|
||||
- SQLite-backed `VarStore` and `TagStore` implementations.
|
||||
- `TagStore` — first-class tags on any CAS node.
|
||||
- `var-store-helpers.ts` — shared validation/history logic.
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.5.3
|
||||
## 0.1.2 — 2026-06-02
|
||||
|
||||
## 0.3.0
|
||||
- Updated dependencies.
|
||||
|
||||
### Minor Changes
|
||||
## 0.1.1 — 2026-06-02
|
||||
|
||||
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
|
||||
- Updated dependencies.
|
||||
|
||||
### Patch Changes
|
||||
## 0.1.0 — 2026-06-01
|
||||
|
||||
- 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. Filesystem-backed CAS store with SQLite indexing and auto-bootstrap.
|
||||
|
||||
+7
-20
@@ -4,7 +4,7 @@ Filesystem-backed CAS store.
|
||||
|
||||
## Overview
|
||||
|
||||
`@ocas/fs` implements a persistent `Store` on disk. Each node is stored as `<hash>.bin` (CBOR-encoded `CasNode`). A `_index/` directory maps type hashes to content hashes for `listByType`. Stores support bootstrap via the same `BOOTSTRAP_STORE` symbol as the in-memory implementation.
|
||||
`@ocas/fs` implements a persistent `Store` backed by `node:sqlite` (`DatabaseSync`). Nodes are stored as CBOR blobs in SQLite tables. Stores support bootstrap via the same `BOOTSTRAP_STORE` symbol as the in-memory implementation.
|
||||
|
||||
Depends on `@ocas/core` for hashing, CBOR encoding, and types.
|
||||
|
||||
@@ -13,7 +13,7 @@ Depends on `@ocas/core` for hashing, CBOR encoding, and types.
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @ocas/fs
|
||||
pnpm add @ocas/fs
|
||||
```
|
||||
|
||||
## API
|
||||
@@ -21,19 +21,18 @@ bun add @ocas/fs
|
||||
Exported from `src/index.ts`:
|
||||
|
||||
```typescript
|
||||
function createFsStore(dir: string): BootstrapCapableStore;
|
||||
function openStore(path: string): Promise<Store>;
|
||||
```
|
||||
|
||||
Returns a `BootstrapCapableStore` from `@ocas/core`. The store loads existing `.bin` files on open and migrates or builds the type index on first use.
|
||||
Returns a unified `Store` with `cas`, `var`, and `tag` sub-stores, backed by SQLite. Bootstraps automatically on open.
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
import { bootstrap, putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { putSchema } from "@ocas/core";
|
||||
import { openStore } from "@ocas/fs";
|
||||
|
||||
const store = createFsStore("./my-cas-store");
|
||||
await bootstrap(store);
|
||||
const store = await openStore("./my-cas-store");
|
||||
|
||||
const typeHash = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -46,18 +45,6 @@ const hash = await store.put(typeHash, { id: "item-1" });
|
||||
console.log(store.has(hash)); // true after restart if same dir
|
||||
```
|
||||
|
||||
### On-disk layout
|
||||
|
||||
```
|
||||
my-cas-store/
|
||||
├── <hash>.bin # CBOR CasNode
|
||||
├── _index/
|
||||
│ └── <typeHash> # newline-separated content hashes
|
||||
└── ...
|
||||
```
|
||||
|
||||
Writes use atomic rename (`<hash>.tmp` → `<hash>.bin`).
|
||||
|
||||
## Internal Structure
|
||||
|
||||
| File | Purpose |
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
{
|
||||
"name": "@ocas/fs",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Filesystem-backed CAS store with SQLite",
|
||||
"keywords": [
|
||||
"cas",
|
||||
"filesystem",
|
||||
"sqlite",
|
||||
"storage"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -14,13 +24,9 @@
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"cborg": "^4.2.3",
|
||||
"@ocas/core": "0.1.0"
|
||||
"@ocas/core": "workspace:*",
|
||||
"cborg": "^4.2.3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -30,5 +36,8 @@
|
||||
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/fs",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { createSqliteVarStore } from "./sqlite-store.js";
|
||||
export { createFsStore, openStore, prepareStore } from "./store.js";
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
CasStore,
|
||||
Hash,
|
||||
HistoryEntry,
|
||||
ListOptions,
|
||||
Tag,
|
||||
TagOp,
|
||||
TagStore,
|
||||
Variable,
|
||||
VarListOptions,
|
||||
VarSetOptions,
|
||||
VarStore,
|
||||
} from "@ocas/core";
|
||||
import {
|
||||
addNameIndex,
|
||||
checkTagLabelConflict,
|
||||
extractSchema,
|
||||
MAX_HISTORY,
|
||||
removeNameIndex,
|
||||
SchemaMismatchError,
|
||||
VariableNotFoundError,
|
||||
type VarRecord,
|
||||
validateName,
|
||||
varKey,
|
||||
} from "@ocas/core";
|
||||
|
||||
function transaction<T>(db: DatabaseSync, fn: () => T): T {
|
||||
db.exec("BEGIN");
|
||||
try {
|
||||
const r = fn();
|
||||
db.exec("COMMIT");
|
||||
return r;
|
||||
} catch (e) {
|
||||
db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const DB_FILE = "_store.db";
|
||||
const VARS_FILE = "_vars.jsonl";
|
||||
const TAGS_FILE = "_tags.jsonl";
|
||||
|
||||
function openDb(dir: string): DatabaseSync {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const db = new DatabaseSync(join(dir, DB_FILE));
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
return db;
|
||||
}
|
||||
|
||||
function initVarTables(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vars (
|
||||
name TEXT NOT NULL,
|
||||
schema TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created INTEGER NOT NULL,
|
||||
updated INTEGER NOT NULL,
|
||||
tags TEXT NOT NULL DEFAULT '{}',
|
||||
labels TEXT NOT NULL DEFAULT '[]',
|
||||
PRIMARY KEY (name, schema)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS var_history (
|
||||
name TEXT NOT NULL,
|
||||
schema TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
set_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (name, schema, position),
|
||||
FOREIGN KEY (name, schema) REFERENCES vars(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vars_name ON vars(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_vars_created ON vars(created);
|
||||
CREATE INDEX IF NOT EXISTS idx_vars_updated ON vars(updated);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_history_pos_desc ON var_history(name, schema, position DESC);
|
||||
`);
|
||||
}
|
||||
|
||||
function initTagTables(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
target TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
created INTEGER NOT NULL,
|
||||
PRIMARY KEY (target, key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_key ON tags(key);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_key_value ON tags(key, value);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── JSONL migration ──
|
||||
|
||||
type StoredTag = {
|
||||
key: string;
|
||||
value: string | null;
|
||||
target: Hash;
|
||||
created: number;
|
||||
};
|
||||
|
||||
function migrateJsonlVars(db: DatabaseSync, dir: string, _cas: CasStore): void {
|
||||
const path = join(dir, VARS_FILE);
|
||||
if (!existsSync(path)) return;
|
||||
|
||||
const records = new Map<string, VarRecord>();
|
||||
const byName = new Map<string, Set<string>>();
|
||||
|
||||
const content = readFileSync(path, "utf8");
|
||||
for (const line of content.split("\n")) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
const rec = JSON.parse(line) as VarRecord & { __op?: string };
|
||||
if (rec.__op === "remove") {
|
||||
const k = varKey(rec.name, rec.schema);
|
||||
records.delete(k);
|
||||
removeNameIndex(byName, rec.name, k);
|
||||
} else {
|
||||
const k = varKey(rec.name, rec.schema);
|
||||
records.set(k, rec);
|
||||
addNameIndex(byName, rec.name, k);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
|
||||
if (records.size === 0) return;
|
||||
|
||||
const insertVar = db.prepare(`
|
||||
INSERT OR REPLACE INTO vars (name, schema, value, created, updated, tags, labels)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const insertHistory = db.prepare(`
|
||||
INSERT OR REPLACE INTO var_history (name, schema, value, position, set_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
transaction(db, () => {
|
||||
for (const rec of records.values()) {
|
||||
insertVar.run(
|
||||
rec.name,
|
||||
rec.schema,
|
||||
rec.value,
|
||||
rec.created,
|
||||
rec.updated,
|
||||
JSON.stringify(rec.tags),
|
||||
JSON.stringify(rec.labels),
|
||||
);
|
||||
for (const h of rec.history) {
|
||||
insertHistory.run(rec.name, rec.schema, h.value, h.position, h.setAt);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function migrateJsonlTags(db: DatabaseSync, dir: string): void {
|
||||
const path = join(dir, TAGS_FILE);
|
||||
if (!existsSync(path)) return;
|
||||
|
||||
const byTarget = new Map<Hash, Map<string, Tag>>();
|
||||
|
||||
const content = readFileSync(path, "utf8");
|
||||
for (const line of content.split("\n")) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
const ent = JSON.parse(line) as
|
||||
| (StoredTag & { __op?: "set" | "untag" })
|
||||
| { __op: "untag"; target: Hash; key: string };
|
||||
if ((ent as { __op?: string }).__op === "untag") {
|
||||
const e = ent as { target: Hash; key: string };
|
||||
const tm = byTarget.get(e.target);
|
||||
if (tm) {
|
||||
tm.delete(e.key);
|
||||
if (tm.size === 0) byTarget.delete(e.target);
|
||||
}
|
||||
} else {
|
||||
const t = ent as StoredTag;
|
||||
let tm = byTarget.get(t.target);
|
||||
if (!tm) {
|
||||
tm = new Map();
|
||||
byTarget.set(t.target, tm);
|
||||
}
|
||||
tm.set(t.key, {
|
||||
key: t.key,
|
||||
value: t.value,
|
||||
target: t.target,
|
||||
created: t.created,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
if (byTarget.size === 0) return;
|
||||
|
||||
const insertTag = db.prepare(`
|
||||
INSERT OR REPLACE INTO tags (target, key, value, created)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
transaction(db, () => {
|
||||
for (const tm of byTarget.values()) {
|
||||
for (const tag of tm.values()) {
|
||||
insertTag.run(tag.target, tag.key, tag.value, tag.created);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Row helpers ──
|
||||
|
||||
function toVariable(row: Record<string, unknown>): Variable {
|
||||
return {
|
||||
name: row.name as string,
|
||||
schema: row.schema as Hash,
|
||||
value: row.value as Hash,
|
||||
created: row.created as number,
|
||||
updated: row.updated as number,
|
||||
tags: JSON.parse(row.tags as string) as Record<string, string>,
|
||||
labels: JSON.parse(row.labels as string) as string[],
|
||||
};
|
||||
}
|
||||
|
||||
function toHistoryEntry(r: Record<string, unknown>): HistoryEntry {
|
||||
return {
|
||||
value: r.value as Hash,
|
||||
position: r.position as number,
|
||||
setAt: r.set_at as number,
|
||||
};
|
||||
}
|
||||
|
||||
function toTag(r: Record<string, unknown>, target: Hash): Tag {
|
||||
return {
|
||||
key: r.key as string,
|
||||
value: r.value as string | null,
|
||||
target,
|
||||
created: r.created as number,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main factory ──
|
||||
|
||||
export function createSqliteVarStore(
|
||||
dir: string,
|
||||
cas: CasStore,
|
||||
): { var: VarStore; tag: TagStore; close: () => void } {
|
||||
const db = openDb(dir);
|
||||
initVarTables(db);
|
||||
initTagTables(db);
|
||||
|
||||
// Migrate JSONL if present (one-time, idempotent)
|
||||
migrateJsonlVars(db, dir, cas);
|
||||
migrateJsonlTags(db, dir);
|
||||
|
||||
let closed = false;
|
||||
|
||||
// ── Prepared statements (var) ──
|
||||
const stmtGetVar = db.prepare(
|
||||
"SELECT * FROM vars WHERE name = ? AND schema = ?",
|
||||
);
|
||||
const stmtGetByName = db.prepare("SELECT * FROM vars WHERE name = ?");
|
||||
const stmtInsertVar = db.prepare(`
|
||||
INSERT INTO vars (name, schema, value, created, updated, tags, labels)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const stmtUpdateVar = db.prepare(`
|
||||
UPDATE vars SET value = ?, updated = ?, tags = ?, labels = ?
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
const stmtDeleteVar = db.prepare(
|
||||
"DELETE FROM vars WHERE name = ? AND schema = ?",
|
||||
);
|
||||
const stmtDeleteVarByName = db.prepare("DELETE FROM vars WHERE name = ?");
|
||||
const stmtInsertHistory = db.prepare(`
|
||||
INSERT INTO var_history (name, schema, value, position, set_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
const stmtGetHistory = db.prepare(
|
||||
"SELECT value, position, set_at FROM var_history WHERE name = ? AND schema = ? ORDER BY position DESC",
|
||||
);
|
||||
const stmtMaxPosition = db.prepare(
|
||||
"SELECT MAX(position) as max_pos FROM var_history WHERE name = ? AND schema = ?",
|
||||
);
|
||||
const stmtDeleteOldHistory = db.prepare(
|
||||
"DELETE FROM var_history WHERE name = ? AND schema = ? AND position NOT IN (SELECT position FROM var_history WHERE name = ? AND schema = ? ORDER BY position DESC LIMIT ?)",
|
||||
);
|
||||
|
||||
// ── Prepared statements (tag) ──
|
||||
const stmtUpsertTag = db.prepare(`
|
||||
INSERT INTO tags (target, key, value, created) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(target, key) DO UPDATE SET value = excluded.value
|
||||
`);
|
||||
const stmtDeleteTag = db.prepare(
|
||||
"DELETE FROM tags WHERE target = ? AND key = ?",
|
||||
);
|
||||
const stmtGetTagsByTarget = db.prepare(
|
||||
"SELECT * FROM tags WHERE target = ? ORDER BY key",
|
||||
);
|
||||
const stmtGetTagsByKey = db.prepare(
|
||||
"SELECT target, key, value, created FROM tags WHERE key = ? ORDER BY created ASC",
|
||||
);
|
||||
const stmtGetTagsByKeyValue = db.prepare(
|
||||
"SELECT target, key, value, created FROM tags WHERE key = ? AND value = ? ORDER BY created ASC",
|
||||
);
|
||||
|
||||
// ── Transactional helpers ──
|
||||
|
||||
function txnSetVar(
|
||||
name: string,
|
||||
schema: Hash,
|
||||
hash: Hash,
|
||||
now: number,
|
||||
tagsJson: string,
|
||||
labelsJson: string,
|
||||
isNew: boolean,
|
||||
valueChanged: boolean,
|
||||
): void {
|
||||
transaction(db, () => {
|
||||
if (isNew) {
|
||||
stmtInsertVar.run(name, schema, hash, now, now, tagsJson, labelsJson);
|
||||
stmtInsertHistory.run(name, schema, hash, 0, now);
|
||||
} else if (valueChanged) {
|
||||
const maxRow = stmtMaxPosition.get(name, schema) as {
|
||||
max_pos: number | null;
|
||||
};
|
||||
const nextPos = (maxRow.max_pos ?? -1) + 1;
|
||||
stmtInsertHistory.run(name, schema, hash, nextPos, now);
|
||||
stmtDeleteOldHistory.run(name, schema, name, schema, MAX_HISTORY);
|
||||
stmtUpdateVar.run(hash, now, tagsJson, labelsJson, name, schema);
|
||||
} else {
|
||||
stmtUpdateVar.run(hash, now, tagsJson, labelsJson, name, schema);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function txnTagOps(target: Hash, operations: TagOp[], now: number): void {
|
||||
transaction(db, () => {
|
||||
for (const op of operations) {
|
||||
if (op.op === "set") {
|
||||
// Use ON CONFLICT to preserve created time — but we need existing created
|
||||
const existing = db
|
||||
.prepare("SELECT created FROM tags WHERE target = ? AND key = ?")
|
||||
.get(target, op.key) as { created: number } | undefined;
|
||||
const created = existing?.created ?? now;
|
||||
stmtUpsertTag.run(target, op.key, op.value ?? null, created);
|
||||
} else {
|
||||
stmtDeleteTag.run(target, op.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function txnUntag(target: Hash, keys: string[]): void {
|
||||
transaction(db, () => {
|
||||
for (const k of keys) {
|
||||
stmtDeleteTag.run(target, k);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── VarStore implementation ──
|
||||
const varStore: VarStore = {
|
||||
set(name: string, hash: Hash, options?: VarSetOptions): Variable {
|
||||
validateName(name);
|
||||
const schema = extractSchema(cas, hash);
|
||||
const existing = stmtGetVar.get(name, schema) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const now = Date.now();
|
||||
|
||||
if (existing) {
|
||||
const v = toVariable(existing);
|
||||
const tags = options?.tags ?? v.tags;
|
||||
const labels = options?.labels ?? v.labels;
|
||||
if (options !== undefined) checkTagLabelConflict(tags, labels);
|
||||
|
||||
const valueChanged = v.value !== hash;
|
||||
const newTags = options !== undefined ? tags : v.tags;
|
||||
const newLabels = options !== undefined ? labels : v.labels;
|
||||
|
||||
if (valueChanged || options !== undefined) {
|
||||
txnSetVar(
|
||||
name,
|
||||
schema,
|
||||
hash,
|
||||
valueChanged ? now : v.updated,
|
||||
JSON.stringify(newTags),
|
||||
JSON.stringify(newLabels),
|
||||
false,
|
||||
valueChanged,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
value: hash,
|
||||
created: v.created,
|
||||
updated: valueChanged ? now : v.updated,
|
||||
tags: { ...newTags },
|
||||
labels: [...newLabels],
|
||||
};
|
||||
}
|
||||
|
||||
// New variable
|
||||
const tags = options?.tags ?? {};
|
||||
const labels = options?.labels ?? [];
|
||||
checkTagLabelConflict(tags, labels);
|
||||
txnSetVar(
|
||||
name,
|
||||
schema,
|
||||
hash,
|
||||
now,
|
||||
JSON.stringify(tags),
|
||||
JSON.stringify(labels),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
value: hash,
|
||||
created: now,
|
||||
updated: now,
|
||||
tags: { ...tags },
|
||||
labels: [...labels],
|
||||
};
|
||||
},
|
||||
|
||||
get(name: string, schema?: Hash): Variable | null {
|
||||
if (schema !== undefined) {
|
||||
const row = stmtGetVar.get(name, schema) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return row ? toVariable(row) : null;
|
||||
}
|
||||
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
|
||||
if (rows.length !== 1) return null;
|
||||
return toVariable(rows[0]!);
|
||||
},
|
||||
|
||||
remove(name: string, schema?: Hash): Variable[] {
|
||||
if (schema !== undefined) {
|
||||
const row = stmtGetVar.get(name, schema) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!row) return [];
|
||||
const v = toVariable(row);
|
||||
stmtDeleteVar.run(name, schema);
|
||||
return [v];
|
||||
}
|
||||
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
|
||||
if (rows.length === 0) return [];
|
||||
const removed = rows.map(toVariable);
|
||||
stmtDeleteVarByName.run(name);
|
||||
return removed;
|
||||
},
|
||||
|
||||
update(name: string, hash: Hash, options?: VarSetOptions): Variable {
|
||||
validateName(name);
|
||||
const newSchema = extractSchema(cas, hash);
|
||||
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
|
||||
if (rows.length === 0) throw new VariableNotFoundError(name, newSchema);
|
||||
|
||||
const existing = stmtGetVar.get(name, newSchema) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!existing) {
|
||||
const first = toVariable(rows[0]!);
|
||||
throw new SchemaMismatchError(first.schema, newSchema);
|
||||
}
|
||||
|
||||
const v = toVariable(existing);
|
||||
const now = Date.now();
|
||||
const tags = options?.tags ?? v.tags;
|
||||
const labels = options?.labels ?? v.labels;
|
||||
if (options !== undefined) checkTagLabelConflict(tags, labels);
|
||||
|
||||
const valueChanged = v.value !== hash;
|
||||
const newTags = options !== undefined ? tags : v.tags;
|
||||
const newLabels = options !== undefined ? labels : v.labels;
|
||||
|
||||
if (valueChanged || options !== undefined) {
|
||||
txnSetVar(
|
||||
name,
|
||||
newSchema,
|
||||
hash,
|
||||
valueChanged ? now : v.updated,
|
||||
JSON.stringify(newTags),
|
||||
JSON.stringify(newLabels),
|
||||
false,
|
||||
valueChanged,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
schema: newSchema,
|
||||
value: hash,
|
||||
created: v.created,
|
||||
updated: valueChanged ? now : v.updated,
|
||||
tags: { ...newTags },
|
||||
labels: [...newLabels],
|
||||
};
|
||||
},
|
||||
|
||||
list(options?: VarListOptions): Variable[] {
|
||||
if (
|
||||
options?.namePrefix !== undefined &&
|
||||
options?.exactName !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"namePrefix and exactName are mutually exclusive - cannot specify both",
|
||||
);
|
||||
}
|
||||
|
||||
const limit = options?.limit;
|
||||
if (limit !== undefined && limit <= 0) return [];
|
||||
|
||||
// Build dynamic query
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
|
||||
if (options?.exactName !== undefined) {
|
||||
conditions.push("name = ?");
|
||||
params.push(options.exactName);
|
||||
}
|
||||
if (options?.namePrefix !== undefined) {
|
||||
conditions.push("name LIKE ? ESCAPE '\\'");
|
||||
const escaped = options.namePrefix
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/%/g, "\\%")
|
||||
.replace(/_/g, "\\_");
|
||||
params.push(`${escaped}%`);
|
||||
}
|
||||
if (options?.schema !== undefined) {
|
||||
conditions.push("schema = ?");
|
||||
params.push(options.schema);
|
||||
}
|
||||
|
||||
const sortCol = options?.sort === "updated" ? "updated" : "created";
|
||||
const sortDir = options?.desc ? "DESC" : "ASC";
|
||||
const where =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
// Post-filter by tags and labels (stored as JSON)
|
||||
const filterTags = options?.tags ?? {};
|
||||
const filterLabels = options?.labels ?? [];
|
||||
const needsPostFilter =
|
||||
Object.keys(filterTags).length > 0 || filterLabels.length > 0;
|
||||
|
||||
// When post-filtering, fetch all matching rows (no SQL LIMIT)
|
||||
// then apply limit/offset after filtering
|
||||
let sql: string;
|
||||
if (needsPostFilter) {
|
||||
sql = `SELECT * FROM vars ${where} ORDER BY ${sortCol} ${sortDir}, name ASC`;
|
||||
} else {
|
||||
sql = `SELECT * FROM vars ${where} ORDER BY ${sortCol} ${sortDir}, name ASC`;
|
||||
if (limit !== undefined || (options?.offset ?? 0) > 0) {
|
||||
sql += ` LIMIT ${limit ?? -1} OFFSET ${options?.offset ?? 0}`;
|
||||
}
|
||||
}
|
||||
|
||||
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
|
||||
|
||||
if (!needsPostFilter) return rows.map(toVariable);
|
||||
|
||||
let results: Variable[] = [];
|
||||
for (const row of rows) {
|
||||
const v = toVariable(row);
|
||||
let ok = true;
|
||||
for (const [tk, tv] of Object.entries(filterTags)) {
|
||||
if (v.tags[tk] !== tv) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) continue;
|
||||
for (const lb of filterLabels) {
|
||||
if (!v.labels.includes(lb)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) results.push(v);
|
||||
}
|
||||
|
||||
// Apply limit/offset after post-filter
|
||||
const offset = options?.offset ?? 0;
|
||||
if (offset > 0) results = results.slice(offset);
|
||||
if (limit !== undefined) results = results.slice(0, limit);
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
history(name: string, schema?: Hash): HistoryEntry[] {
|
||||
if (schema !== undefined) {
|
||||
return (
|
||||
stmtGetHistory.all(name, schema) as Record<string, unknown>[]
|
||||
).map(toHistoryEntry);
|
||||
}
|
||||
const vars = stmtGetByName.all(name) as Record<string, unknown>[];
|
||||
if (vars.length !== 1) return [];
|
||||
const v = vars[0]!;
|
||||
return (
|
||||
stmtGetHistory.all(v.name as string, v.schema as string) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]
|
||||
).map(toHistoryEntry);
|
||||
},
|
||||
|
||||
close(): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
|
||||
// ── TagStore implementation ──
|
||||
const tagStore: TagStore = {
|
||||
tag(target: Hash, operations: TagOp[]): Tag[] {
|
||||
const now = Date.now();
|
||||
txnTagOps(target, operations, now);
|
||||
return (stmtGetTagsByTarget.all(target) as Record<string, unknown>[]).map(
|
||||
(r) => toTag(r, target),
|
||||
);
|
||||
},
|
||||
|
||||
untag(target: Hash, keys: string[]): void {
|
||||
txnUntag(target, keys);
|
||||
},
|
||||
|
||||
tags(target: Hash): Tag[] {
|
||||
return (stmtGetTagsByTarget.all(target) as Record<string, unknown>[]).map(
|
||||
(r) => toTag(r, target),
|
||||
);
|
||||
},
|
||||
|
||||
listByTag(tag: string, options?: ListOptions): Hash[] {
|
||||
let key = tag;
|
||||
let value: string | null | undefined;
|
||||
const eqIdx = tag.indexOf("=");
|
||||
if (eqIdx >= 0) {
|
||||
key = tag.slice(0, eqIdx);
|
||||
value = tag.slice(eqIdx + 1);
|
||||
}
|
||||
|
||||
// Build SQL with sort/limit/offset pushed down
|
||||
const sortCol = "created"; // tags only have created
|
||||
const sortDir = options?.desc ? "DESC" : "ASC";
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit;
|
||||
|
||||
let sql: string;
|
||||
const params: (string | number | null)[] = [key];
|
||||
if (value !== undefined) {
|
||||
sql = `SELECT target FROM tags WHERE key = ? AND value = ? ORDER BY ${sortCol} ${sortDir}`;
|
||||
params.push(value);
|
||||
} else {
|
||||
sql = `SELECT target FROM tags WHERE key = ? ORDER BY ${sortCol} ${sortDir}`;
|
||||
}
|
||||
if (limit !== undefined || offset > 0) {
|
||||
sql += ` LIMIT ${limit ?? -1} OFFSET ${offset}`;
|
||||
}
|
||||
|
||||
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
|
||||
return rows.map((r) => r.target as Hash);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
var: varStore,
|
||||
tag: tagStore,
|
||||
close: () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
+610
-43
@@ -1,10 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
computeSelfHash,
|
||||
verify,
|
||||
} from "@ocas/core";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createFsStore, openStore } from "./store.js";
|
||||
|
||||
@@ -50,24 +52,24 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
});
|
||||
|
||||
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const store = await openStore(dir);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
|
||||
const node = store.get(hash) as CasNode;
|
||||
const node = store.cas.get(hash) as CasNode;
|
||||
expect(node.type).toBe(hash);
|
||||
});
|
||||
|
||||
test("bootstrap is idempotent across calls", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const h1 = await bootstrap(store);
|
||||
const h2 = await bootstrap(store);
|
||||
const store = await openStore(dir);
|
||||
const h1 = bootstrap(store);
|
||||
const h2 = bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
|
||||
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(32);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,8 +114,8 @@ describe("createFsStore – persistence round-trip", () => {
|
||||
});
|
||||
|
||||
test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const store1 = await openStore(dir);
|
||||
const builtinSchemas = bootstrap(store1);
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
@@ -260,8 +262,8 @@ describe("createFsStore – listByType", () => {
|
||||
});
|
||||
|
||||
test("bootstrap node is listed under its self type after reload", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const store1 = await openStore(dir);
|
||||
const builtinSchemas = bootstrap(store1);
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
@@ -294,8 +296,8 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
|
||||
});
|
||||
|
||||
test("verify passes on a disk-loaded bootstrap node", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const store1 = await openStore(dir);
|
||||
const builtinSchemas = bootstrap(store1);
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
@@ -336,8 +338,8 @@ describe("openStore – async with auto-bootstrap", () => {
|
||||
test("openStore returns Promise<Store>", async () => {
|
||||
const store = await openStore(dir);
|
||||
expect(store).toBeDefined();
|
||||
expect(typeof store.put).toBe("function");
|
||||
expect(typeof store.get).toBe("function");
|
||||
expect(typeof store.cas.put).toBe("function");
|
||||
expect(typeof store.cas.get).toBe("function");
|
||||
});
|
||||
|
||||
test("openStore auto-creates directory when it doesn't exist", async () => {
|
||||
@@ -349,19 +351,19 @@ describe("openStore – async with auto-bootstrap", () => {
|
||||
|
||||
// Verify store works
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
const hash = await store.put(typeHash, { x: 1 });
|
||||
expect(store.has(hash)).toBe(true);
|
||||
const hash = store.cas.put(typeHash, { x: 1 });
|
||||
expect(store.cas.has(hash)).toBe(true);
|
||||
});
|
||||
|
||||
test("openStore works when directory already exists", async () => {
|
||||
// Pre-create the directory
|
||||
const store1 = await openStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
await store1.put(typeHash, { x: 1 });
|
||||
store1.cas.put(typeHash, { x: 1 });
|
||||
|
||||
// Open again
|
||||
const store2 = await openStore(dir);
|
||||
expect(store2.listByType(typeHash)).toHaveLength(1);
|
||||
expect(store2.cas.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("openStore throws error when path exists but is not a directory", async () => {
|
||||
@@ -375,29 +377,29 @@ describe("openStore – async with auto-bootstrap", () => {
|
||||
const store = await openStore(dir);
|
||||
|
||||
// Check that bootstrap schemas exist
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"];
|
||||
|
||||
expect(metaHash).toBeDefined();
|
||||
expect(store.has(metaHash as string)).toBe(true);
|
||||
expect(store.cas.has(metaHash as string)).toBe(true);
|
||||
|
||||
// Verify all core schemas exist
|
||||
expect(store.has(builtinSchemas["@ocas/string"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@ocas/number"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@ocas/object"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@ocas/array"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@ocas/bool"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@ocas/schema"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/string"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/number"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/object"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/array"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/bool"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/schema"] as string)).toBe(true);
|
||||
});
|
||||
|
||||
test("openStore bootstrap is idempotent on subsequent opens", async () => {
|
||||
const store1 = await openStore(dir);
|
||||
const schemas1 = await bootstrap(store1);
|
||||
const count1 = store1.listAll().length;
|
||||
const schemas1 = bootstrap(store1);
|
||||
const count1 = store1.cas.listAll().length;
|
||||
|
||||
const store2 = await openStore(dir);
|
||||
const schemas2 = await bootstrap(store2);
|
||||
const count2 = store2.listAll().length;
|
||||
const schemas2 = bootstrap(store2);
|
||||
const count2 = store2.cas.listAll().length;
|
||||
|
||||
// Same schemas, same count
|
||||
expect(schemas1).toEqual(schemas2);
|
||||
@@ -405,30 +407,30 @@ describe("openStore – async with auto-bootstrap", () => {
|
||||
});
|
||||
|
||||
test("openStore works on already-bootstrapped store", async () => {
|
||||
// Bootstrap manually first
|
||||
const store1 = createFsStore(dir);
|
||||
const schemas1 = await bootstrap(store1);
|
||||
// Open + bootstrap
|
||||
const store1 = await openStore(dir);
|
||||
const schemas1 = bootstrap(store1);
|
||||
|
||||
// Open with openStore
|
||||
// Open again
|
||||
const store2 = await openStore(dir);
|
||||
const schemas2 = await bootstrap(store2);
|
||||
const schemas2 = bootstrap(store2);
|
||||
|
||||
expect(schemas1).toEqual(schemas2);
|
||||
});
|
||||
|
||||
test("openStore auto-bootstraps old store without bootstrap", async () => {
|
||||
// Create a store with some data but no bootstrap
|
||||
const store1 = createFsStore(dir);
|
||||
// Create a CAS store with some data but no bootstrap
|
||||
const cas1 = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "custom" });
|
||||
await store1.put(typeHash, { data: "old" });
|
||||
cas1.put(typeHash, { data: "old" });
|
||||
|
||||
// Open with openStore - should auto-bootstrap
|
||||
const store2 = await openStore(dir);
|
||||
const schemas = await bootstrap(store2);
|
||||
const schemas = bootstrap(store2);
|
||||
|
||||
expect(store2.has(schemas["@ocas/schema"] as string)).toBe(true);
|
||||
expect(store2.cas.has(schemas["@ocas/schema"] as string)).toBe(true);
|
||||
// Old data still exists
|
||||
expect(store2.listByType(typeHash)).toHaveLength(1);
|
||||
expect(store2.cas.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -558,3 +560,568 @@ describe("createFsStore – listMeta and listSchemas", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// E2. Store shape from openStore
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("openStore – Store shape", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = makeTmpDir();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("E2. returns object with cas, var, tag sub-stores", async () => {
|
||||
const store = await openStore(dir);
|
||||
expect(typeof store.cas).toBe("object");
|
||||
expect(typeof store.var).toBe("object");
|
||||
expect(typeof store.tag).toBe("object");
|
||||
expect(typeof store.cas.put).toBe("function");
|
||||
expect(typeof store.cas.get).toBe("function");
|
||||
expect(typeof store.cas.has).toBe("function");
|
||||
expect(typeof store.var.set).toBe("function");
|
||||
expect(typeof store.var.get).toBe("function");
|
||||
expect(typeof store.var.list).toBe("function");
|
||||
expect(typeof store.var.history).toBe("function");
|
||||
expect(typeof store.tag.tag).toBe("function");
|
||||
expect(typeof store.tag.tags).toBe("function");
|
||||
expect(typeof store.tag.listByTag).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// nodes/ subdirectory layout (#84)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createFsStore – nodes/ subdirectory layout", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = makeTmpDir();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A. New layout – nodes written to nodes/ subdirectory
|
||||
|
||||
test("A1. put() writes .bin file to dir/nodes/, not dir/", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
const hash = await store.put(typeHash, { x: 1 });
|
||||
|
||||
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(true);
|
||||
expect(existsSync(join(dir, `${hash}.bin`))).toBe(false);
|
||||
});
|
||||
|
||||
test("A2. putSelfReferencing (BOOTSTRAP_STORE) writes to dir/nodes/", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
|
||||
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(true);
|
||||
expect(existsSync(join(dir, `${hash}.bin`))).toBe(false);
|
||||
});
|
||||
|
||||
test("A3. nodes/ directory auto-created on first put", async () => {
|
||||
expect(existsSync(join(dir, "nodes"))).toBe(false);
|
||||
|
||||
const store = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
await store.put(typeHash, { x: 1 });
|
||||
|
||||
expect(existsSync(join(dir, "nodes"))).toBe(true);
|
||||
expect(statSync(join(dir, "nodes")).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
// B. Round-trip with new layout
|
||||
|
||||
test("B1. second createFsStore instance reads nodes from dir/nodes/", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "B1" });
|
||||
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1.put(typeHash, { msg: "hello" });
|
||||
const h2 = await store1.put(typeHash, { msg: "world" });
|
||||
|
||||
// Confirm files are in nodes/, not root
|
||||
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
|
||||
expect(existsSync(join(dir, "nodes", `${h2}.bin`))).toBe(true);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.has(h1)).toBe(true);
|
||||
expect(store2.has(h2)).toBe(true);
|
||||
expect(store2.listByType(typeHash)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("B2. openStore round-trip: bootstrap + put + reload all intact", async () => {
|
||||
const store1 = await openStore(dir);
|
||||
const schemas1 = bootstrap(store1);
|
||||
const schemaHash = schemas1["@ocas/schema"] ?? "";
|
||||
const typeHash = await computeSelfHash({ name: "B2" });
|
||||
const userHash = store1.cas.put(typeHash, { x: 42 });
|
||||
|
||||
// All node files should be in nodes/
|
||||
const nodeEntries = readdirSync(join(dir, "nodes"));
|
||||
expect(nodeEntries.some((e) => e === `${schemaHash}.bin`)).toBe(true);
|
||||
expect(nodeEntries.some((e) => e === `${userHash}.bin`)).toBe(true);
|
||||
|
||||
const store2 = await openStore(dir);
|
||||
expect(store2.cas.has(schemaHash)).toBe(true);
|
||||
expect(store2.cas.has(userHash)).toBe(true);
|
||||
});
|
||||
|
||||
// C. Migration from old flat layout
|
||||
|
||||
test("C1. old-layout .bin files in dir/ root are moved to dir/nodes/ on createFsStore", async () => {
|
||||
// Manually build an "old" layout by writing nodes via a fresh store first
|
||||
// then renaming files from nodes/ back to root, simulating pre-#84 stores.
|
||||
const typeHash = await computeSelfHash({ name: "C1" });
|
||||
const tmp = createFsStore(dir);
|
||||
const h1 = await tmp.put(typeHash, { i: 1 });
|
||||
const h2 = await tmp.put(typeHash, { i: 2 });
|
||||
|
||||
// Simulate old flat layout: move .bin files from nodes/ to root
|
||||
const nodesDir = join(dir, "nodes");
|
||||
for (const f of readdirSync(nodesDir)) {
|
||||
renameSync(join(nodesDir, f), join(dir, f));
|
||||
}
|
||||
rmSync(nodesDir, { recursive: true, force: true });
|
||||
|
||||
expect(existsSync(join(dir, `${h1}.bin`))).toBe(true);
|
||||
expect(existsSync(join(dir, `${h2}.bin`))).toBe(true);
|
||||
expect(existsSync(nodesDir)).toBe(false);
|
||||
|
||||
// Now open the store; migration should run
|
||||
const _store = createFsStore(dir);
|
||||
|
||||
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
|
||||
expect(existsSync(join(dir, "nodes", `${h2}.bin`))).toBe(true);
|
||||
});
|
||||
|
||||
test("C2. after migration, no .bin files remain in dir/ root", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "C2" });
|
||||
const tmp = createFsStore(dir);
|
||||
await tmp.put(typeHash, { i: 1 });
|
||||
await tmp.put(typeHash, { i: 2 });
|
||||
|
||||
// Simulate old flat layout
|
||||
const nodesDir = join(dir, "nodes");
|
||||
for (const f of readdirSync(nodesDir)) {
|
||||
renameSync(join(nodesDir, f), join(dir, f));
|
||||
}
|
||||
rmSync(nodesDir, { recursive: true, force: true });
|
||||
|
||||
const _store = createFsStore(dir);
|
||||
|
||||
const rootEntries = readdirSync(dir);
|
||||
const binFilesInRoot = rootEntries.filter((e) => e.endsWith(".bin"));
|
||||
expect(binFilesInRoot).toEqual([]);
|
||||
});
|
||||
|
||||
test("C3. after migration, all nodes accessible via get() and listByType()", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "C3" });
|
||||
const tmp = createFsStore(dir);
|
||||
const h1 = await tmp.put(typeHash, { i: 1 });
|
||||
const h2 = await tmp.put(typeHash, { i: 2 });
|
||||
const h3 = await tmp.put(typeHash, { i: 3 });
|
||||
|
||||
// Simulate old flat layout: move .bin to root, drop _index so we re-build
|
||||
const nodesDir = join(dir, "nodes");
|
||||
for (const f of readdirSync(nodesDir)) {
|
||||
renameSync(join(nodesDir, f), join(dir, f));
|
||||
}
|
||||
rmSync(nodesDir, { recursive: true, force: true });
|
||||
rmSync(join(dir, "_index"), { recursive: true, force: true });
|
||||
|
||||
const store = createFsStore(dir);
|
||||
expect(store.has(h1)).toBe(true);
|
||||
expect(store.has(h2)).toBe(true);
|
||||
expect(store.has(h3)).toBe(true);
|
||||
const listed = store.listByType(typeHash).map((e) => e.hash);
|
||||
expect(listed).toHaveLength(3);
|
||||
expect(listed).toContain(h1);
|
||||
expect(listed).toContain(h2);
|
||||
expect(listed).toContain(h3);
|
||||
});
|
||||
|
||||
test("C4. migration is idempotent: re-opening already-migrated store is no-op", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "C4" });
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1.put(typeHash, { i: 1 });
|
||||
await store1.put(typeHash, { i: 2 });
|
||||
|
||||
const nodesDirBefore = readdirSync(join(dir, "nodes")).sort();
|
||||
const rootEntriesBefore = readdirSync(dir).sort();
|
||||
|
||||
// Re-open — should be a no-op (no migration occurs)
|
||||
const _store2 = createFsStore(dir);
|
||||
|
||||
const nodesDirAfter = readdirSync(join(dir, "nodes")).sort();
|
||||
const rootEntriesAfter = readdirSync(dir).sort();
|
||||
|
||||
expect(nodesDirAfter).toEqual(nodesDirBefore);
|
||||
expect(rootEntriesAfter).toEqual(rootEntriesBefore);
|
||||
// Sanity: file from before migration is still in nodes/
|
||||
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
|
||||
});
|
||||
|
||||
test("C5. openStore on old layout: migrates, bootstraps, put/get all work", async () => {
|
||||
// Build an "old" store: bootstrap then move .bin to root
|
||||
const store1 = await openStore(dir);
|
||||
const schemas1 = bootstrap(store1);
|
||||
const schemaHash = schemas1["@ocas/schema"] ?? "";
|
||||
|
||||
const nodesDir = join(dir, "nodes");
|
||||
for (const f of readdirSync(nodesDir)) {
|
||||
renameSync(join(nodesDir, f), join(dir, f));
|
||||
}
|
||||
rmSync(nodesDir, { recursive: true, force: true });
|
||||
|
||||
expect(existsSync(join(dir, `${schemaHash}.bin`))).toBe(true);
|
||||
|
||||
// Re-open with openStore — should migrate + bootstrap idempotently
|
||||
const store2 = await openStore(dir);
|
||||
expect(store2.cas.has(schemaHash)).toBe(true);
|
||||
expect(existsSync(join(dir, "nodes", `${schemaHash}.bin`))).toBe(true);
|
||||
expect(existsSync(join(dir, `${schemaHash}.bin`))).toBe(false);
|
||||
|
||||
// put/get works after migration
|
||||
const typeHash = await computeSelfHash({ name: "C5" });
|
||||
const newHash = store2.cas.put(typeHash, { hello: "world" });
|
||||
expect(store2.cas.has(newHash)).toBe(true);
|
||||
expect(existsSync(join(dir, "nodes", `${newHash}.bin`))).toBe(true);
|
||||
});
|
||||
|
||||
// D. Delete
|
||||
|
||||
test("D1. delete() removes dir/nodes/<hash>.bin (not dir/<hash>.bin)", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "D1" });
|
||||
const hash = await store.put(typeHash, { x: 1 });
|
||||
|
||||
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(true);
|
||||
|
||||
const removed = store.delete(hash);
|
||||
expect(removed).toBe(true);
|
||||
|
||||
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(false);
|
||||
expect(existsSync(join(dir, `${hash}.bin`))).toBe(false);
|
||||
expect(store.has(hash)).toBe(false);
|
||||
});
|
||||
|
||||
// E. Metadata location unchanged
|
||||
|
||||
test("E1. _index/ stays in dir/ (not inside nodes/)", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "E1" });
|
||||
await store.put(typeHash, { x: 1 });
|
||||
|
||||
expect(existsSync(join(dir, "_index"))).toBe(true);
|
||||
expect(statSync(join(dir, "_index")).isDirectory()).toBe(true);
|
||||
expect(existsSync(join(dir, "nodes", "_index"))).toBe(false);
|
||||
});
|
||||
|
||||
test("E2. _store.db stays in dir/ (not inside nodes/)", async () => {
|
||||
const store = await openStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "E2" });
|
||||
store.cas.put(typeHash, { x: 1 });
|
||||
|
||||
expect(existsSync(join(dir, "_store.db"))).toBe(true);
|
||||
expect(existsSync(join(dir, "nodes", "_store.db"))).toBe(false);
|
||||
});
|
||||
|
||||
// F. Index rebuild with new layout
|
||||
|
||||
test("F1. removing _index/ then re-opening rebuilds index from dir/nodes/ .bin files", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "F1" });
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1.put(typeHash, { a: 1 });
|
||||
const h2 = await store1.put(typeHash, { a: 2 });
|
||||
|
||||
rmSync(join(dir, "_index"), { recursive: true, force: true });
|
||||
expect(existsSync(join(dir, "_index"))).toBe(false);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const list = store2.listByType(typeHash).map((e) => e.hash);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list).toContain(h1);
|
||||
expect(list).toContain(h2);
|
||||
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
|
||||
|
||||
// sanity: .bin files still in nodes/
|
||||
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
|
||||
expect(existsSync(join(dir, "nodes", `${h2}.bin`))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Lazy loading (#85)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createFsStore – lazy loading (#85)", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = makeTmpDir();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("L1. createFsStore does NOT CBOR-decode nodes at startup", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "L1" });
|
||||
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1.put(typeHash, { i: 1 });
|
||||
const h2 = await store1.put(typeHash, { i: 2 });
|
||||
const h3 = await store1.put(typeHash, { i: 3 });
|
||||
|
||||
// Corrupt h2 by overwriting its .bin file with garbage CBOR
|
||||
const corruptedPath = join(dir, "nodes", `${h2}.bin`);
|
||||
writeFileSync(corruptedPath, Buffer.from([0xff, 0xfe, 0xfd, 0xfc]));
|
||||
|
||||
// Opening the store should NOT throw, even though h2 is corrupted —
|
||||
// because nothing is decoded at startup.
|
||||
const store2 = createFsStore(dir);
|
||||
|
||||
// has() should return true for all three (filename-based)
|
||||
expect(store2.has(h1)).toBe(true);
|
||||
expect(store2.has(h2)).toBe(true);
|
||||
expect(store2.has(h3)).toBe(true);
|
||||
|
||||
// listAll() reads filenames, so all three appear
|
||||
const all = store2.listAll();
|
||||
expect(all).toContain(h1);
|
||||
expect(all).toContain(h2);
|
||||
expect(all).toContain(h3);
|
||||
|
||||
// Non-corrupted nodes load fine
|
||||
expect(store2.get(h1)).not.toBeNull();
|
||||
expect(store2.get(h3)).not.toBeNull();
|
||||
|
||||
// Corrupted node fails to load (returns null)
|
||||
expect(store2.get(h2)).toBeNull();
|
||||
});
|
||||
|
||||
test("L2. get() loads node from disk on demand (cache miss)", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "L2" });
|
||||
|
||||
const store1 = createFsStore(dir);
|
||||
const hash = await store1.put(typeHash, { value: 42, label: "answer" });
|
||||
const original = store1.get(hash) as CasNode;
|
||||
|
||||
// Lazy-load instance
|
||||
const store2 = createFsStore(dir);
|
||||
const loaded1 = store2.get(hash) as CasNode;
|
||||
expect(loaded1.type).toBe(typeHash);
|
||||
expect(loaded1.payload).toEqual({ value: 42, label: "answer" });
|
||||
expect(loaded1.timestamp).toBe(original.timestamp);
|
||||
|
||||
// Second get should return the same data (from cache)
|
||||
const loaded2 = store2.get(hash) as CasNode;
|
||||
expect(loaded2).toEqual(loaded1);
|
||||
});
|
||||
|
||||
test("L3. has() works without loading node data", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "L3" });
|
||||
|
||||
const store1 = createFsStore(dir);
|
||||
const hashes: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
hashes.push(await store1.put(typeHash, { i }));
|
||||
}
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
for (const hash of hashes) {
|
||||
expect(store2.has(hash)).toBe(true);
|
||||
}
|
||||
|
||||
// Non-existent hash returns false
|
||||
expect(store2.has("0000000000000")).toBe(false);
|
||||
});
|
||||
|
||||
test("L4. listAll() returns hashes from filenames without decoding", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "L4" });
|
||||
|
||||
const store1 = createFsStore(dir);
|
||||
const realHashes: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
realHashes.push(await store1.put(typeHash, { i }));
|
||||
}
|
||||
|
||||
// Add a corrupted .bin file with valid filename but garbage content
|
||||
const corruptedHash = "ABCDEFGHJKMNP";
|
||||
writeFileSync(
|
||||
join(dir, "nodes", `${corruptedHash}.bin`),
|
||||
Buffer.from([0xff, 0xee, 0xdd]),
|
||||
);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const all = store2.listAll();
|
||||
expect(all).toHaveLength(realHashes.length + 1);
|
||||
for (const h of realHashes) {
|
||||
expect(all).toContain(h);
|
||||
}
|
||||
expect(all).toContain(corruptedHash);
|
||||
|
||||
// Real nodes still readable
|
||||
for (const h of realHashes) {
|
||||
expect(store2.get(h)).not.toBeNull();
|
||||
}
|
||||
// Corrupted one returns null
|
||||
expect(store2.get(corruptedHash)).toBeNull();
|
||||
});
|
||||
|
||||
test("L5. put() makes node immediately available without re-reading disk", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "L5" });
|
||||
|
||||
const hash = await store.put(typeHash, { written: true });
|
||||
|
||||
// Immediately available via get(), has(), and listAll()
|
||||
const node = store.get(hash) as CasNode;
|
||||
expect(node.type).toBe(typeHash);
|
||||
expect(node.payload).toEqual({ written: true });
|
||||
expect(store.has(hash)).toBe(true);
|
||||
expect(store.listAll()).toContain(hash);
|
||||
});
|
||||
|
||||
test("L6. delete() removes node from cache and disk", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "L6" });
|
||||
|
||||
const hash = await store.put(typeHash, { temporary: true });
|
||||
// populate cache by getting once
|
||||
expect(store.get(hash)).not.toBeNull();
|
||||
|
||||
expect(store.delete(hash)).toBe(true);
|
||||
|
||||
expect(store.get(hash)).toBeNull();
|
||||
expect(store.has(hash)).toBe(false);
|
||||
expect(store.listAll()).not.toContain(hash);
|
||||
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(false);
|
||||
});
|
||||
|
||||
test("L7. listByType works with lazy loading (loads timestamps on demand)", async () => {
|
||||
const typeA = await computeSelfHash({ name: "typeA-L7" });
|
||||
const typeB = await computeSelfHash({ name: "typeB-L7" });
|
||||
|
||||
const store1 = createFsStore(dir);
|
||||
const aHashes: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
aHashes.push(await store1.put(typeA, { i }));
|
||||
}
|
||||
const bHashes: string[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
bHashes.push(await store1.put(typeB, { i }));
|
||||
}
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const aList = store2.listByType(typeA);
|
||||
expect(aList).toHaveLength(3);
|
||||
for (const e of aList) {
|
||||
expect(aHashes).toContain(e.hash);
|
||||
expect(typeof e.created).toBe("number");
|
||||
expect(e.created).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const bList = store2.listByType(typeB);
|
||||
expect(bList).toHaveLength(2);
|
||||
|
||||
expect(store2.listByType("0000000000000")).toEqual([]);
|
||||
});
|
||||
|
||||
test("L8. listMeta works with lazy loading", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const m1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L8a" });
|
||||
const m2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L8b" });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const meta = store2.listMeta();
|
||||
const metaHashes = meta.map((e) => e.hash);
|
||||
expect(metaHashes).toHaveLength(2);
|
||||
expect(metaHashes).toContain(m1);
|
||||
expect(metaHashes).toContain(m2);
|
||||
for (const e of meta) {
|
||||
expect(typeof e.created).toBe("number");
|
||||
expect(e.created).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("L9. listSchemas works with lazy loading", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const m = await store1[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const s1 = await store1.put(m, { type: "string" });
|
||||
const s2 = await store1.put(m, { type: "number" });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const schemas = store2.listSchemas().map((e) => e.hash);
|
||||
expect(schemas).toHaveLength(3);
|
||||
expect(schemas).toContain(m);
|
||||
expect(schemas).toContain(s1);
|
||||
expect(schemas).toContain(s2);
|
||||
});
|
||||
|
||||
test("L10. index migration still works with lazy loading", async () => {
|
||||
const typeHash = await computeSelfHash({ name: "L10" });
|
||||
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1.put(typeHash, { i: 1 });
|
||||
const h2 = await store1.put(typeHash, { i: 2 });
|
||||
|
||||
rmSync(join(dir, "_index"), { recursive: true, force: true });
|
||||
|
||||
// Re-open: should rebuild type index by scanning + decoding nodes on disk
|
||||
const store2 = createFsStore(dir);
|
||||
const list = store2.listByType(typeHash).map((e) => e.hash);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list).toContain(h1);
|
||||
expect(list).toContain(h2);
|
||||
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
|
||||
|
||||
// Re-open again: index already on disk, no re-scan needed
|
||||
const store3 = createFsStore(dir);
|
||||
const list3 = store3.listByType(typeHash).map((e) => e.hash);
|
||||
expect(list3).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("L11. meta migration still works with lazy loading", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L11a" });
|
||||
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L11b" });
|
||||
|
||||
const metaPath = join(dir, "_index", "_meta");
|
||||
rmSync(metaPath, { force: true });
|
||||
expect(existsSync(metaPath)).toBe(false);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const meta = store2.listMeta().map((e) => e.hash);
|
||||
expect(meta).toHaveLength(2);
|
||||
expect(meta).toContain(h1);
|
||||
expect(meta).toContain(h2);
|
||||
expect(existsSync(metaPath)).toBe(true);
|
||||
});
|
||||
|
||||
test("L12. bootstrap round-trip works with lazy store", async () => {
|
||||
const store1 = await openStore(dir);
|
||||
const schemas1 = bootstrap(store1);
|
||||
const typeHash = await computeSelfHash({ name: "L12-user" });
|
||||
const userHash = store1.cas.put(typeHash, { user: "data" });
|
||||
|
||||
const store2 = await openStore(dir);
|
||||
// All bootstrap schemas accessible
|
||||
for (const name of [
|
||||
"@ocas/schema",
|
||||
"@ocas/string",
|
||||
"@ocas/number",
|
||||
"@ocas/object",
|
||||
"@ocas/array",
|
||||
"@ocas/bool",
|
||||
]) {
|
||||
const h = schemas1[name] as string;
|
||||
expect(store2.cas.has(h)).toBe(true);
|
||||
expect(store2.cas.get(h)).not.toBeNull();
|
||||
}
|
||||
// User data still accessible
|
||||
expect(store2.cas.has(userHash)).toBe(true);
|
||||
const userNode = store2.cas.get(userHash) as CasNode;
|
||||
expect(userNode.payload).toEqual({ user: "data" });
|
||||
});
|
||||
});
|
||||
|
||||
+182
-105
@@ -10,46 +10,85 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
BootstrapCapableStore,
|
||||
CasNode,
|
||||
Hash,
|
||||
ListEntry,
|
||||
ListOptions,
|
||||
VariableStore,
|
||||
} from "@ocas/core";
|
||||
|
||||
import {
|
||||
applyListOptions,
|
||||
BOOTSTRAP_STORE,
|
||||
type BootstrapCapableStore,
|
||||
bootstrap,
|
||||
type CasNode,
|
||||
casListEntry,
|
||||
cborEncode,
|
||||
computeHash,
|
||||
computeSelfHash,
|
||||
computeHashSync,
|
||||
computeSelfHashSync,
|
||||
type Hash,
|
||||
initHasher,
|
||||
type ListEntry,
|
||||
type ListOptions,
|
||||
type Store,
|
||||
} from "@ocas/core";
|
||||
import { decode } from "cborg";
|
||||
import { createSqliteVarStore } from "./sqlite-store.js";
|
||||
|
||||
const INDEX_DIR = "_index";
|
||||
const META_FILE = "_meta";
|
||||
const NODES_DIR = "nodes";
|
||||
|
||||
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
|
||||
// Initialise the xxhash WASM instance once at module load so the FS CAS
|
||||
// store can use the synchronous hashing functions.
|
||||
await initHasher();
|
||||
|
||||
/**
|
||||
* Migrate any pre-#84 flat-layout `.bin` files at the store root into the
|
||||
* `nodes/` subdirectory. Idempotent — does nothing if no `.bin` files are
|
||||
* present at the root.
|
||||
*/
|
||||
function migrateFlatLayoutToNodes(dir: string): void {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const binFiles = entries.filter((name) => name.endsWith(".bin"));
|
||||
if (binFiles.length === 0) return;
|
||||
|
||||
const nodesDir = join(dir, NODES_DIR);
|
||||
mkdirSync(nodesDir, { recursive: true });
|
||||
for (const name of binFiles) {
|
||||
renameSync(join(dir, name), join(nodesDir, name));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `nodes/` directory for `.bin` filenames and return the set of hashes
|
||||
* present on disk. Does NOT read or decode any node content — this is the
|
||||
* cheap O(n) startup operation that replaces the legacy full-load.
|
||||
*/
|
||||
function loadHashSet(dir: string): Set<Hash> {
|
||||
const hashes = new Set<Hash>();
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return hashes;
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith(".bin")) continue;
|
||||
const hash = name.slice(0, -4) as Hash;
|
||||
try {
|
||||
const buf = readFileSync(join(dir, name));
|
||||
const node = decode(new Uint8Array(buf)) as CasNode;
|
||||
data.set(hash, node);
|
||||
} catch {
|
||||
// skip corrupted files
|
||||
}
|
||||
hashes.add(name.slice(0, -4) as Hash);
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and CBOR-decode a single node from disk. Returns `null` if the file
|
||||
* is missing or its content is corrupted.
|
||||
*/
|
||||
function readNodeFromDisk(nodesDir: string, hash: Hash): CasNode | null {
|
||||
try {
|
||||
const buf = readFileSync(join(nodesDir, `${hash}.bin`));
|
||||
return decode(new Uint8Array(buf)) as CasNode;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +116,19 @@ function loadTypeIndex(indexDir: string): Map<Hash, Hash[]> {
|
||||
return typeIndex;
|
||||
}
|
||||
|
||||
function buildTypeIndexFromNodes(data: Map<Hash, CasNode>): Map<Hash, Hash[]> {
|
||||
/**
|
||||
* Migration helper: scan all `.bin` files on disk, decoding each one to read
|
||||
* its `type` field, and rebuild the type index. Used only when `_index/` is
|
||||
* missing — a one-time cost.
|
||||
*/
|
||||
function buildTypeIndexFromDisk(
|
||||
nodesDir: string,
|
||||
hashSet: Set<Hash>,
|
||||
): Map<Hash, Hash[]> {
|
||||
const typeIndex = new Map<Hash, Hash[]>();
|
||||
for (const [hash, node] of data) {
|
||||
for (const hash of hashSet) {
|
||||
const node = readNodeFromDisk(nodesDir, hash);
|
||||
if (!node) continue;
|
||||
const list = typeIndex.get(node.type) ?? [];
|
||||
list.push(hash);
|
||||
typeIndex.set(node.type, list);
|
||||
@@ -97,11 +146,12 @@ function writeTypeIndex(indexDir: string, typeIndex: Map<Hash, Hash[]>): void {
|
||||
|
||||
function loadOrMigrateTypeIndex(
|
||||
dir: string,
|
||||
data: Map<Hash, CasNode>,
|
||||
nodesDir: string,
|
||||
hashSet: Set<Hash>,
|
||||
): Map<Hash, Hash[]> {
|
||||
const indexDir = join(dir, INDEX_DIR);
|
||||
if (!existsSync(indexDir)) {
|
||||
const typeIndex = buildTypeIndexFromNodes(data);
|
||||
const typeIndex = buildTypeIndexFromDisk(nodesDir, hashSet);
|
||||
if (typeIndex.size > 0) {
|
||||
writeTypeIndex(indexDir, typeIndex);
|
||||
}
|
||||
@@ -112,7 +162,8 @@ function loadOrMigrateTypeIndex(
|
||||
|
||||
function loadOrMigrateMetaSet(
|
||||
dir: string,
|
||||
data: Map<Hash, CasNode>,
|
||||
nodesDir: string,
|
||||
hashSet: Set<Hash>,
|
||||
): Set<Hash> {
|
||||
const indexDir = join(dir, INDEX_DIR);
|
||||
const metaPath = join(indexDir, META_FILE);
|
||||
@@ -124,10 +175,11 @@ function loadOrMigrateMetaSet(
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
// Migration: scan loaded nodes for self-referencing nodes (type === hash)
|
||||
// Migration: scan nodes on disk for self-referencing nodes (type === hash)
|
||||
const metaSet = new Set<Hash>();
|
||||
for (const [hash, node] of data) {
|
||||
if (node.type === hash) {
|
||||
for (const hash of hashSet) {
|
||||
const node = readNodeFromDisk(nodesDir, hash);
|
||||
if (node && node.type === hash) {
|
||||
metaSet.add(hash);
|
||||
}
|
||||
}
|
||||
@@ -178,34 +230,60 @@ function appendToTypeIndex(
|
||||
typeIndex.set(type, list);
|
||||
}
|
||||
|
||||
function hashesToEntries(
|
||||
data: Map<Hash, CasNode>,
|
||||
hashes: Iterable<Hash>,
|
||||
): ListEntry[] {
|
||||
const result: ListEntry[] = [];
|
||||
for (const h of hashes) {
|
||||
const node = data.get(h);
|
||||
if (node) result.push(casListEntry(h, node.timestamp));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* The CAS sub-store of an FS-backed `Store` — also satisfies the legacy
|
||||
* `BootstrapCapableStore` interface so `bootstrap()` can run against it.
|
||||
*/
|
||||
export type FsCasStore = BootstrapCapableStore & {
|
||||
put(typeHash: Hash, payload: unknown): Hash;
|
||||
delete(hash: Hash): boolean;
|
||||
};
|
||||
|
||||
export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
const data = new Map<Hash, CasNode>();
|
||||
loadDir(dir, data);
|
||||
export function createFsStore(dir: string): FsCasStore {
|
||||
// Migrate any pre-#84 flat-layout .bin files at the root into nodes/.
|
||||
migrateFlatLayoutToNodes(dir);
|
||||
|
||||
const nodesDir = join(dir, NODES_DIR);
|
||||
// Lazy loading (#85): only scan filenames at startup — do NOT decode.
|
||||
const hashSet = loadHashSet(nodesDir);
|
||||
// In-memory cache of decoded nodes. Populated on first get() of each hash.
|
||||
const cache = new Map<Hash, CasNode>();
|
||||
const indexDir = join(dir, INDEX_DIR);
|
||||
const typeIndex = loadOrMigrateTypeIndex(dir, data);
|
||||
const metaSet = loadOrMigrateMetaSet(dir, data);
|
||||
const typeIndex = loadOrMigrateTypeIndex(dir, nodesDir, hashSet);
|
||||
const metaSet = loadOrMigrateMetaSet(dir, nodesDir, hashSet);
|
||||
|
||||
async function putSelfReferencing(payload: unknown): Promise<Hash> {
|
||||
const hash = await computeSelfHash(payload);
|
||||
if (!data.has(hash)) {
|
||||
/**
|
||||
* Look up a node by hash, loading from disk on cache miss. Returns `null`
|
||||
* if the hash is unknown or the file is corrupted.
|
||||
*/
|
||||
function loadNode(hash: Hash): CasNode | null {
|
||||
const cached = cache.get(hash);
|
||||
if (cached) return cached;
|
||||
if (!hashSet.has(hash)) return null;
|
||||
const node = readNodeFromDisk(nodesDir, hash);
|
||||
if (node) cache.set(hash, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function hashesToEntries(hashes: Iterable<Hash>): ListEntry[] {
|
||||
const result: ListEntry[] = [];
|
||||
for (const h of hashes) {
|
||||
const node = loadNode(h);
|
||||
if (node) result.push(casListEntry(h, node.timestamp));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function putSelfReferencing(payload: unknown): Hash {
|
||||
const hash = computeSelfHashSync(payload);
|
||||
if (!hashSet.has(hash)) {
|
||||
const node: CasNode = { type: hash, payload, timestamp: Date.now() };
|
||||
data.set(hash, node);
|
||||
hashSet.add(hash);
|
||||
cache.set(hash, node);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const tmp = join(dir, `${hash}.tmp`);
|
||||
const dest = join(dir, `${hash}.bin`);
|
||||
mkdirSync(nodesDir, { recursive: true });
|
||||
const tmp = join(nodesDir, `${hash}.tmp`);
|
||||
const dest = join(nodesDir, `${hash}.bin`);
|
||||
writeFileSync(
|
||||
tmp,
|
||||
cborEncode({ type: hash, payload, timestamp: node.timestamp }),
|
||||
@@ -218,21 +296,22 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
return hash;
|
||||
}
|
||||
|
||||
const store: BootstrapCapableStore = {
|
||||
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
const store: FsCasStore = {
|
||||
put(typeHash: Hash, payload: unknown): Hash {
|
||||
const hash = computeHashSync(typeHash, payload);
|
||||
|
||||
if (!data.has(hash)) {
|
||||
if (!hashSet.has(hash)) {
|
||||
const node: CasNode = {
|
||||
type: typeHash,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
data.set(hash, node);
|
||||
hashSet.add(hash);
|
||||
cache.set(hash, node);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const tmp = join(dir, `${hash}.tmp`);
|
||||
const dest = join(dir, `${hash}.bin`);
|
||||
mkdirSync(nodesDir, { recursive: true });
|
||||
const tmp = join(nodesDir, `${hash}.tmp`);
|
||||
const dest = join(nodesDir, `${hash}.bin`);
|
||||
writeFileSync(
|
||||
tmp,
|
||||
cborEncode({ type: typeHash, payload, timestamp: node.timestamp }),
|
||||
@@ -246,25 +325,25 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
},
|
||||
|
||||
get(hash: Hash): CasNode | null {
|
||||
return data.get(hash) ?? null;
|
||||
return loadNode(hash);
|
||||
},
|
||||
|
||||
has(hash: Hash): boolean {
|
||||
return data.has(hash);
|
||||
return hashSet.has(hash);
|
||||
},
|
||||
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
|
||||
const list = typeIndex.get(typeHash);
|
||||
if (!list) return [];
|
||||
return applyListOptions(hashesToEntries(data, list), options);
|
||||
return applyListOptions(hashesToEntries(list), options);
|
||||
},
|
||||
|
||||
listAll(): Hash[] {
|
||||
return Array.from(data.keys());
|
||||
return Array.from(hashSet);
|
||||
},
|
||||
|
||||
listMeta(options?: ListOptions): ListEntry[] {
|
||||
return applyListOptions(hashesToEntries(data, metaSet), options);
|
||||
return applyListOptions(hashesToEntries(metaSet), options);
|
||||
},
|
||||
|
||||
listSchemas(options?: ListOptions): ListEntry[] {
|
||||
@@ -276,20 +355,23 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
for (const h of list) result.add(h);
|
||||
}
|
||||
}
|
||||
return applyListOptions(hashesToEntries(data, result), options);
|
||||
return applyListOptions(hashesToEntries(result), options);
|
||||
},
|
||||
|
||||
delete(hash: Hash): void {
|
||||
const node = data.get(hash);
|
||||
delete(hash: Hash): boolean {
|
||||
if (!hashSet.has(hash)) return false;
|
||||
// Need the node's type to clean up the type index. Lazy-load if needed.
|
||||
const node = loadNode(hash);
|
||||
hashSet.delete(hash);
|
||||
cache.delete(hash);
|
||||
// Delete file
|
||||
try {
|
||||
unlinkSync(join(nodesDir, `${hash}.bin`));
|
||||
} catch {
|
||||
// ignore if file doesn't exist
|
||||
}
|
||||
// Remove from type index (only if we could decode the node)
|
||||
if (node) {
|
||||
data.delete(hash);
|
||||
// Delete file
|
||||
try {
|
||||
unlinkSync(join(dir, `${hash}.bin`));
|
||||
} catch {
|
||||
// ignore if file doesn't exist
|
||||
}
|
||||
// Remove from type index
|
||||
const list = typeIndex.get(node.type);
|
||||
if (list) {
|
||||
const idx = list.indexOf(hash);
|
||||
@@ -310,12 +392,13 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
writeFileSync(join(indexDir, node.type), body, "utf8");
|
||||
}
|
||||
}
|
||||
// Remove from meta set if applicable
|
||||
if (metaSet.has(hash)) {
|
||||
metaSet.delete(hash);
|
||||
rewriteMetaSet(indexDir, metaSet);
|
||||
}
|
||||
}
|
||||
// Remove from meta set if applicable
|
||||
if (metaSet.has(hash)) {
|
||||
metaSet.delete(hash);
|
||||
rewriteMetaSet(indexDir, metaSet);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
[BOOTSTRAP_STORE]: putSelfReferencing,
|
||||
@@ -325,19 +408,16 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a filesystem-backed CAS store: create the directory (if needed),
|
||||
* Prepare a filesystem-backed CAS sub-store: create the directory (if needed),
|
||||
* validate that the path is a directory, and instantiate the store. Does NOT
|
||||
* run bootstrap — callers that want bootstrap should either use {@link openStore}
|
||||
* or call `bootstrap` themselves (useful when wiring a varStore before
|
||||
* bootstrap to avoid running it twice).
|
||||
* or call `bootstrap` themselves.
|
||||
*
|
||||
* @param dir - The directory path for the store
|
||||
* @returns A Promise resolving to the BootstrapCapableStore
|
||||
* @returns A Promise resolving to the FsCasStore
|
||||
* @throws Error if the path exists but is not a directory
|
||||
*/
|
||||
export async function prepareStore(
|
||||
dir: string,
|
||||
): Promise<BootstrapCapableStore> {
|
||||
export async function prepareStore(dir: string): Promise<FsCasStore> {
|
||||
// Create directory if it doesn't exist
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -374,25 +454,22 @@ export async function prepareStore(
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem-backed CAS store with automatic directory creation and bootstrap.
|
||||
* This is an async function that:
|
||||
* 1. Creates the directory (with recursive: true) if it doesn't exist
|
||||
* 2. Validates that the path is actually a directory (not a file)
|
||||
* 3. Creates the store
|
||||
* 4. Runs bootstrap (which is idempotent)
|
||||
* Open a filesystem-backed `Store` with automatic directory creation and
|
||||
* bootstrap. The CAS sub-store is FS-backed; the variable and tag sub-stores
|
||||
* are in-memory (provided by `@ocas/core`).
|
||||
*
|
||||
* @param dir - The directory path for the store
|
||||
* @param varStore - Optional variable store; when provided, builtin schema
|
||||
* aliases are written to it during bootstrap
|
||||
* @returns A Promise resolving to the BootstrapCapableStore
|
||||
* @param dir - The directory path for the CAS store
|
||||
* @returns A Promise resolving to the Store
|
||||
* @throws Error if the path exists but is not a directory
|
||||
*/
|
||||
export async function openStore(
|
||||
dir: string,
|
||||
varStore?: VariableStore,
|
||||
): Promise<BootstrapCapableStore> {
|
||||
const store = await prepareStore(dir);
|
||||
// Bootstrap (idempotent)
|
||||
await bootstrap(store, varStore);
|
||||
return store;
|
||||
export async function openStore(dir: string): Promise<Store> {
|
||||
const cas = await prepareStore(dir);
|
||||
const sqlite = createSqliteVarStore(dir, cas);
|
||||
const ocas: Store = {
|
||||
cas,
|
||||
var: sqlite.var,
|
||||
tag: sqlite.tag,
|
||||
};
|
||||
bootstrap(ocas);
|
||||
return ocas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
const T1 = "AAAAAAAAAAAAA";
|
||||
const T2 = "BBBBBBBBBBBBB";
|
||||
|
||||
describe("FsTagStore", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "ocas-fs-tag-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("B1. set tag with key/value round-trip + SQLite persisted", async () => {
|
||||
const store = await openStore(dir);
|
||||
const result = store.tag.tag(T1, [
|
||||
{ op: "set", key: "env", value: "prod" },
|
||||
]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.key).toBe("env");
|
||||
expect(result[0]?.value).toBe("prod");
|
||||
expect(store.tag.tags(T1)).toEqual(result);
|
||||
|
||||
const dbFile = join(dir, "_store.db");
|
||||
expect(existsSync(dbFile)).toBe(true);
|
||||
});
|
||||
|
||||
test("B2. label tag (no value) records value: null", async () => {
|
||||
const store = await openStore(dir);
|
||||
store.tag.tag(T1, [{ op: "set", key: "pinned" }]);
|
||||
const tags = store.tag.tags(T1);
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]?.value).toBeNull();
|
||||
});
|
||||
|
||||
test("B3. multiple ops in one call sorted by key", async () => {
|
||||
const store = await openStore(dir);
|
||||
const result = store.tag.tag(T1, [
|
||||
{ op: "set", key: "b", value: "2" },
|
||||
{ op: "set", key: "a", value: "1" },
|
||||
]);
|
||||
expect(result.map((t) => t.key)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("B4. update existing key overwrites value", async () => {
|
||||
const store = await openStore(dir);
|
||||
store.tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
|
||||
store.tag.tag(T1, [{ op: "set", key: "env", value: "dev" }]);
|
||||
const tags = store.tag.tags(T1);
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]?.value).toBe("dev");
|
||||
});
|
||||
|
||||
test("B5. delete via tag op removes the entry", async () => {
|
||||
const store = await openStore(dir);
|
||||
store.tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
|
||||
store.tag.tag(T1, [{ op: "delete", key: "env" }]);
|
||||
expect(store.tag.tags(T1)).toEqual([]);
|
||||
});
|
||||
|
||||
test("B6. untag removes listed keys; missing keys silently skipped", async () => {
|
||||
const store = await openStore(dir);
|
||||
store.tag.tag(T1, [
|
||||
{ op: "set", key: "a", value: "1" },
|
||||
{ op: "set", key: "b", value: "2" },
|
||||
]);
|
||||
store.tag.untag(T1, ["a", "missing"]);
|
||||
expect(store.tag.tags(T1).map((t) => t.key)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
test("B7. listByTag bare key returns all tagged targets", async () => {
|
||||
const store = await openStore(dir);
|
||||
store.tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
|
||||
store.tag.tag(T2, [{ op: "set", key: "env", value: "dev" }]);
|
||||
const listed = store.tag.listByTag("env").sort();
|
||||
expect(listed).toEqual([T1, T2].sort());
|
||||
});
|
||||
|
||||
test("B8. listByTag key=value filters by exact value", async () => {
|
||||
const store = await openStore(dir);
|
||||
store.tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
|
||||
store.tag.tag(T2, [{ op: "set", key: "env", value: "dev" }]);
|
||||
expect(store.tag.listByTag("env=prod")).toEqual([T1]);
|
||||
});
|
||||
|
||||
test("B9. ListOptions on listByTag (limit, offset)", async () => {
|
||||
const store = await openStore(dir);
|
||||
const targets: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const t = `${"C".repeat(12)}${i}`;
|
||||
targets.push(t);
|
||||
store.tag.tag(t, [{ op: "set", key: "k", value: String(i) }]);
|
||||
}
|
||||
expect(store.tag.listByTag("k", { limit: 2 })).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("B10. persistence across reopen", async () => {
|
||||
const store = await openStore(dir);
|
||||
store.tag.tag(T1, [
|
||||
{ op: "set", key: "env", value: "prod" },
|
||||
{ op: "set", key: "team", value: "platform" },
|
||||
]);
|
||||
const reopened = await openStore(dir);
|
||||
const tags = reopened.tag.tags(T1);
|
||||
expect(tags.map((t) => t.key)).toEqual(["env", "team"]);
|
||||
expect(tags.map((t) => t.value)).toEqual(["prod", "platform"]);
|
||||
});
|
||||
|
||||
test("B11. SQLite replay fidelity (set/delete/untag mix)", async () => {
|
||||
const store = await openStore(dir);
|
||||
store.tag.tag(T1, [{ op: "set", key: "a", value: "1" }]);
|
||||
store.tag.tag(T1, [{ op: "set", key: "b", value: "2" }]);
|
||||
store.tag.tag(T1, [{ op: "set", key: "c", value: "3" }]);
|
||||
store.tag.tag(T1, [{ op: "delete", key: "b" }]);
|
||||
store.tag.untag(T1, ["a"]);
|
||||
|
||||
const reopened = await openStore(dir);
|
||||
const tags = reopened.tag.tags(T1);
|
||||
expect(tags.map((t) => t.key)).toEqual(["c"]);
|
||||
expect(tags[0]?.value).toBe("3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import {
|
||||
CasNodeNotFoundError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
} from "@ocas/core";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
const META_TYPE_KEY = Symbol.for("@ocas/core/bootstrap-store");
|
||||
|
||||
async function setupStore(dir: string): Promise<{
|
||||
store: Store;
|
||||
schema: Hash;
|
||||
put: (payload: unknown) => Hash;
|
||||
}> {
|
||||
const store = await openStore(dir);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: bootstrap symbol access
|
||||
const meta = (store.cas as any)[META_TYPE_KEY]({ type: "object" }) as Hash;
|
||||
const schema = store.cas.put(meta, { type: "string" });
|
||||
return {
|
||||
store,
|
||||
schema,
|
||||
put: (payload) => store.cas.put(schema, payload),
|
||||
};
|
||||
}
|
||||
|
||||
describe("FsVarStore", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "ocas-fs-var-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("A1. set + get round-trip persists to SQLite", async () => {
|
||||
const { store, schema, put } = await setupStore(dir);
|
||||
const h = put("hello");
|
||||
const v = store.var.set("@app/x", h);
|
||||
expect(v.name).toBe("@app/x");
|
||||
expect(v.value).toBe(h);
|
||||
expect(v.schema).toBe(schema);
|
||||
|
||||
const got = store.var.get("@app/x", schema);
|
||||
expect(got?.value).toBe(h);
|
||||
|
||||
const dbFile = join(dir, "_store.db");
|
||||
expect(existsSync(dbFile)).toBe(true);
|
||||
});
|
||||
|
||||
test("A2. name validation", async () => {
|
||||
const { store, put } = await setupStore(dir);
|
||||
const h = put("v");
|
||||
expect(() => store.var.set("x", h)).toThrow(InvalidVariableNameError);
|
||||
expect(() => store.var.set("@/x", h)).toThrow(InvalidVariableNameError);
|
||||
expect(() => store.var.set("@app/", h)).toThrow(InvalidVariableNameError);
|
||||
expect(() => store.var.set("@app//x", h)).toThrow(InvalidVariableNameError);
|
||||
expect(() => store.var.set("@app/x.y_z-1", h)).not.toThrow();
|
||||
});
|
||||
|
||||
test("A3. set throws CasNodeNotFoundError if hash absent", async () => {
|
||||
const { store } = await setupStore(dir);
|
||||
expect(() => store.var.set("@app/x", "ZZZZZZZZZZZZZ")).toThrow(
|
||||
CasNodeNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
test("A4. idempotent same-value set", async () => {
|
||||
const { store, schema, put } = await setupStore(dir);
|
||||
const h = put("v");
|
||||
const v1 = store.var.set("@app/x", h);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const v2 = store.var.set("@app/x", h);
|
||||
expect(v2.updated).toBe(v1.updated);
|
||||
expect(store.var.history("@app/x", schema)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("A5. update via re-set bumps updated and appends history", async () => {
|
||||
const { store, schema, put } = await setupStore(dir);
|
||||
const h1 = put("v1");
|
||||
const h2 = put("v2");
|
||||
const v1 = store.var.set("@app/x", h1);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const v2 = store.var.set("@app/x", h2);
|
||||
expect(v2.updated).toBeGreaterThan(v1.updated);
|
||||
const hist = store.var.history("@app/x", schema);
|
||||
expect(hist.map((e) => e.value)).toEqual([h2, h1]);
|
||||
});
|
||||
|
||||
test("A6. SchemaMismatchError on update with different schema", async () => {
|
||||
const { store, put } = await setupStore(dir);
|
||||
const h = put("v");
|
||||
store.var.set("@app/x", h);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: bootstrap symbol access
|
||||
const meta = (store.cas as any)[META_TYPE_KEY]({ type: "object" }) as Hash;
|
||||
const otherSchema = store.cas.put(meta, { type: "number" });
|
||||
const h2 = store.cas.put(otherSchema, 42);
|
||||
expect(() => store.var.update("@app/x", h2)).toThrow(SchemaMismatchError);
|
||||
});
|
||||
|
||||
test("A7. remove clears get and list", async () => {
|
||||
const { store, schema, put } = await setupStore(dir);
|
||||
const h = put("v");
|
||||
store.var.set("@app/x", h);
|
||||
const removed = store.var.remove("@app/x", schema);
|
||||
expect(removed).toHaveLength(1);
|
||||
expect(store.var.get("@app/x", schema)).toBeNull();
|
||||
const listed = store.var.list({ exactName: "@app/x" });
|
||||
expect(listed).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("A8. list with ListOptions: sort/limit/offset/desc", async () => {
|
||||
const { store, put } = await setupStore(dir);
|
||||
const h1 = put("v1");
|
||||
const h2 = put("v2");
|
||||
store.var.set("@user/a", h1);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
store.var.set("@user/b", h2);
|
||||
|
||||
const limited = store.var.list({ namePrefix: "@user/", limit: 1 });
|
||||
expect(limited).toHaveLength(1);
|
||||
|
||||
const offset = store.var.list({ namePrefix: "@user/", offset: 1 });
|
||||
expect(offset.map((v) => v.name)).toContain("@user/b");
|
||||
|
||||
const desc = store.var.list({ namePrefix: "@user/", desc: true });
|
||||
expect(desc[0]?.name).toBe("@user/b");
|
||||
});
|
||||
|
||||
test("A9. persistence across reopen", async () => {
|
||||
const { store, put } = await setupStore(dir);
|
||||
const h = put("v-persist");
|
||||
store.var.set("@app/p", h);
|
||||
store.var.close();
|
||||
|
||||
const reopened = await openStore(dir);
|
||||
const got = reopened.var.list({ exactName: "@app/p" });
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0]?.value).toBe(h);
|
||||
});
|
||||
|
||||
test("A10. MAX_HISTORY truncation", async () => {
|
||||
const { store, schema, put } = await setupStore(dir);
|
||||
for (let i = 0; i < MAX_HISTORY + 3; i++) {
|
||||
const h = put(`v${i}`);
|
||||
store.var.set("@app/x", h);
|
||||
}
|
||||
const hist = store.var.history("@app/x", schema);
|
||||
expect(hist).toHaveLength(MAX_HISTORY);
|
||||
});
|
||||
|
||||
test("A11. labels round-trip", async () => {
|
||||
const { store, schema, put } = await setupStore(dir);
|
||||
const h = put("v");
|
||||
store.var.set("@app/x", h, { labels: ["pinned"] });
|
||||
const got = store.var.get("@app/x", schema);
|
||||
expect(got?.labels).toEqual(["pinned"]);
|
||||
});
|
||||
|
||||
test("A12. TagLabelConflictError when labels and tags overlap", async () => {
|
||||
const { store, put } = await setupStore(dir);
|
||||
const h = put("v");
|
||||
expect(() =>
|
||||
store.var.set("@app/x", h, {
|
||||
tags: { env: "prod" },
|
||||
labels: ["env"],
|
||||
}),
|
||||
).toThrow(TagLabelConflictError);
|
||||
});
|
||||
|
||||
test("A13. update on missing variable throws VariableNotFoundError", async () => {
|
||||
const { store, put } = await setupStore(dir);
|
||||
const h = put("v");
|
||||
expect(() => store.var.update("@app/missing", h)).toThrow(
|
||||
VariableNotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
Generated
+2261
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user