Compare commits
108 Commits
547c026095
..
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 60d7897206 | |||
| 54188a1015 | |||
| adc984a653 | |||
| 6578311a93 | |||
| 68779f76c6 | |||
| 0b7883efc2 | |||
| 3859187989 | |||
| b99d74093c | |||
| bfaa2722fc | |||
| 48743cbf4f | |||
| 8504abbb5a | |||
| 5463b44554 | |||
| 22b5474a77 | |||
| 190ae672a7 | |||
| ed19466a3b | |||
| 92a024fc1c | |||
| 93947c35d0 | |||
| d83173107a |
+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
|
||||
|
||||
|
||||
+17
-5
@@ -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,15 +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>]
|
||||
ocas var rollback <name> <position> [--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
|
||||
@@ -73,6 +81,10 @@ ocas render --pipe/-p [options]
|
||||
| `--render`, `-r` | Render output inline (equivalent to piping to `ocas render -p`) |
|
||||
| `--inline <text>` | Inline text content for `template set` |
|
||||
| `--format tree` | Tree display for `walk` |
|
||||
| `--sort created\|updated` | Sort key for list commands (default: `created`; for CAS nodes both are equivalent) |
|
||||
| `--limit <n>` | Max results to return (default: 100) |
|
||||
| `--offset <n>` | Skip first N results (default: 0) |
|
||||
| `--desc` | Sort descending (default: ascending) |
|
||||
|
||||
## Variable Names
|
||||
|
||||
@@ -84,4 +96,4 @@ ocas put @ocas/schema schema.json # auto-routes to putSchema()
|
||||
ocas list --type @ocas/schema # list all schemas
|
||||
```
|
||||
|
||||
User-defined variable names work the same way — once `ocas var set myapp/config <hash>` is registered, `ocas get myapp/config` resolves to that hash.
|
||||
All variable names must follow `@scope/name` format. User-defined names work just like builtins — once `ocas var set @myapp/config <hash>` is registered, `ocas get @myapp/config` resolves to that hash.
|
||||
|
||||
+29
-14
@@ -13,17 +13,36 @@ 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;
|
||||
delete(hash: Hash): void;
|
||||
listAll(): Hash[];
|
||||
listByType(typeHash: Hash): Hash[];
|
||||
listMeta(): Hash[];
|
||||
listSchemas(): Hash[];
|
||||
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:
|
||||
|
||||
```typescript
|
||||
type ListSort = "created" | "updated";
|
||||
type ListOptions = { sort?: ListSort; desc?: boolean; limit?: number; offset?: number };
|
||||
type ListEntry = { hash: Hash; created: number; updated: number };
|
||||
```
|
||||
|
||||
When `limit` is `undefined`, all results are returned (no cap). The [[CLI]] defaults `limit` to 100.
|
||||
|
||||
`put()` computes the [[Content Addressing|hash]] from `{ type, payload }`, validates the payload against its [[Schema]], and stores the [[Content Addressing|node]]. If a node with the same hash already exists, it's a no-op — content addressing gives deduplication for free.
|
||||
|
||||
## Implementations
|
||||
@@ -32,16 +51,12 @@ type Store = {
|
||||
|
||||
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. Nodes are serialized as CBOR files under a content-addressed directory tree. Created via `openStore(path)`, which:
|
||||
|
||||
1. Creates the directory if it doesn't exist
|
||||
2. Runs [[Bootstrap]] automatically
|
||||
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.
|
||||
|
||||
+24
-7
@@ -27,21 +27,36 @@ type Variable = {
|
||||
|
||||
The primary key is `(name, schema)` — the same name can point to nodes of different types. When using `var set`, the schema is automatically inferred from the node the hash points to, so you don't need to specify it explicitly.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
All variable names must follow `@scope/name` format:
|
||||
|
||||
- **Pattern**: `@[a-zA-Z][a-zA-Z0-9]*/` followed by one or more segments of `[a-zA-Z0-9._-]+`
|
||||
- **`@ocas/*`** is reserved for internal use (builtin schemas, templates)
|
||||
- **Examples**: `@myapp/config`, `@todo/schema`, `@data/users.prod`
|
||||
- **Rejected**: `config` (no scope), `foo/bar` (no `@`), `@/foo` (empty scope), `@123/foo` (digit scope)
|
||||
|
||||
The `@scope` prefix ensures variable names are visually distinct from 13-character hashes.
|
||||
|
||||
## Operations
|
||||
|
||||
```bash
|
||||
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`)
|
||||
@@ -62,13 +77,15 @@ Every variable tracks its last `MAX_HISTORY` (default 10) values with LRU rotati
|
||||
|
||||
- **set()** appends to history: if the new value already exists in history, it rotates to position 0; otherwise it's inserted at 0 and the oldest entry beyond MAX_HISTORY is evicted.
|
||||
- **Idempotent**: setting the same value as the current (position 0) is a no-op — important for [[Bootstrap]] which calls set() repeatedly.
|
||||
- **rollback(name, schema, position)** promotes a historical value back to current.
|
||||
|
||||
```bash
|
||||
ocas var history <name> [--schema <hash-or-name>] # show history, [0] = current
|
||||
ocas var rollback <name> <position> [--schema <hash-or-name>] # restore a previous value
|
||||
```
|
||||
|
||||
## Sorting & Pagination
|
||||
|
||||
`var list` and `var history` support the common list flags: `--sort created|updated`, `--limit`, `--offset`, `--desc`. The CLI defaults `--limit` to 100; the core layer returns all results when `limit` is omitted.
|
||||
|
||||
## Role in Garbage Collection
|
||||
|
||||
Variables are the **roots** of [[Garbage Collection]]. Any node reachable from a variable's value hash (via [[Schema|ocas_ref]] edges) is kept alive; unreachable nodes are swept.
|
||||
|
||||
@@ -3,3 +3,4 @@ dist/
|
||||
*.d.ts.map
|
||||
*.tsbuildinfo
|
||||
.worktrees/
|
||||
bun.lock
|
||||
|
||||
@@ -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:** Changesets + `pnpm publish` → npmjs (`@ocas/*`)
|
||||
|
||||
## 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,15 +59,21 @@ 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`)
|
||||
|
||||
### List Utilities
|
||||
|
||||
`packages/core/src/list-utils.ts` provides `applyListOptions()` — in-memory sort/paginate for `ListEntry[]` arrays. Used by `MemoryStore`; `FsStore` pushes sort/limit to SQLite. Core layer treats `limit: undefined` as "no limit"; the CLI defaults to 100 in `parseListOptions()`.
|
||||
|
||||
### Architecture Notes
|
||||
|
||||
- **No "alias" concept** — every name resolution flows through the `VariableStore`. Builtin schemas (`@ocas/schema`, `@ocas/string`, `@ocas/output/*`, …) are registered as variables during `bootstrap(store, varStore)`, alongside user-defined variables created via `ocas var set`.
|
||||
- **`bootstrap(store, varStore?)`** writes builtin name → hash bindings into the varStore when one is provided; called automatically by `openStore()` and `openStoreAndVarStore()`.
|
||||
- **`resolveHash(input, varStore)`** is the unified hash/name resolver in the CLI. If `input` matches the 13-char hash format it is returned as-is; otherwise the varStore is queried by exact name. This means every CLI command that accepts a hash argument also accepts a variable name (schema names, user vars, etc.).
|
||||
- **`openStoreAndVarStore()`** in the CLI opens both stores and bootstraps once; prefer it over separate `openStore()` + `createVariableStore()` to avoid double bootstrap.
|
||||
- **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.
|
||||
- **`openStore()`** returns a unified `Store` with `cas`, `var`, and `tag` sub-stores, and bootstraps automatically. `@ocas/core` has zero SQLite dependency.
|
||||
|
||||
### Internal Dependencies
|
||||
|
||||
@@ -85,48 +92,71 @@ This is resolved to real version numbers only during publishing (see below).
|
||||
|
||||
## 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.
|
||||
Releases use a **release branch** workflow with three phases: prepare → candidate → finalize.
|
||||
|
||||
### Prepare
|
||||
|
||||
```bash
|
||||
./scripts/prepare-release.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
1. Checks you're on `main` with a clean tree and pending changesets
|
||||
2. Creates `release/<version>` branch
|
||||
3. Runs `changeset version` to fix versions and generate CHANGELOGs
|
||||
4. Runs full validation (install, build, lint, test)
|
||||
5. Commits the version bump
|
||||
|
||||
After preparation, review changes and fix any issues on the release branch.
|
||||
|
||||
### Publish
|
||||
|
||||
```bash
|
||||
./scripts/publish.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
1. Validates you're on a `release/*` branch with no pending changesets
|
||||
2. Runs final build + test
|
||||
3. Publishes packages in order: `@ocas/core` → `@ocas/fs` → `@ocas/cli`
|
||||
4. Tags, pushes, merges back to `main`, cleans up the release branch
|
||||
`main` always keeps `workspace:*` for internal deps; release branches fix them to real versions.
|
||||
Changeset files are **only consumed once** during finalize — prerelease (rc) never touches them.
|
||||
|
||||
### Adding a Changeset
|
||||
|
||||
Before releasing, add changesets for your changes:
|
||||
Add changesets alongside feature PRs on `main`:
|
||||
|
||||
```bash
|
||||
bunx changeset # interactive — pick packages + bump type + summary
|
||||
```markdown
|
||||
<!-- .changeset/my-change.md -->
|
||||
---
|
||||
"@ocas/cli": patch
|
||||
---
|
||||
|
||||
Description of the change
|
||||
```
|
||||
|
||||
Changesets live in `.changeset/` as markdown files until consumed by `prepare-release.sh`.
|
||||
Changesets live in `.changeset/` as markdown files. Bump types: `patch` / `minor` / `major`.
|
||||
One changeset can cover multiple packages.
|
||||
|
||||
### Phase 1: Prepare (cut release branch)
|
||||
|
||||
- **Precondition:** on `main`, clean tree, `.changeset/` has pending changesets
|
||||
- **Steps:**
|
||||
1. Determine target version (from changeset bump types or manually)
|
||||
2. `git checkout -b release/<version>`
|
||||
3. Fix `workspace:*` → real version numbers in all `package.json`
|
||||
4. Commit
|
||||
- **Does NOT** run `changeset version`, does NOT write CHANGELOG
|
||||
|
||||
### Phase 2: Candidate (publish rc for validation)
|
||||
|
||||
- **Precondition:** on `release/*` branch
|
||||
- **Steps:**
|
||||
1. Set version to `<version>-rc.N` (first time rc.1, increment on subsequent runs)
|
||||
2. `pnpm install && pnpm run build && pnpm run test && pnpm run check`
|
||||
3. Publish: `pnpm publish --tag rc` (order: core → fs → cli)
|
||||
4. Commit + push
|
||||
- **Repeatable:** fix bugs → add new changesets on the release branch → rc.N+1
|
||||
- **Does NOT** consume changesets, does NOT write CHANGELOG
|
||||
- Install for testing: `pnpm add -g @ocas/cli@rc`
|
||||
|
||||
### Phase 3: Finalize (official release)
|
||||
|
||||
- **Precondition:** on `release/*` branch, rc validated
|
||||
- **Steps:**
|
||||
1. Consume all `.changeset/*.md` → write CHANGELOG entries (use `changeset version` or manual)
|
||||
2. Set final version `<version>` (remove `-rc.N`)
|
||||
3. `pnpm install && pnpm run build && pnpm run test && pnpm run check`
|
||||
4. Publish: `pnpm publish --tag latest` (order: core → fs → cli)
|
||||
5. Git tag `v<version>`
|
||||
6. Merge back to `main` (CHANGELOG comes along)
|
||||
7. Restore `workspace:*` on `main`
|
||||
8. Delete release branch
|
||||
|
||||
### Key Rules
|
||||
|
||||
- **Publish order** is always `@ocas/core` → `@ocas/fs` → `@ocas/cli`
|
||||
- **`workspace:*`** must be fixed before any publish — `pnpm publish` does NOT auto-replace them
|
||||
- **CHANGELOG** only contains official releases, never rc entries
|
||||
- **Changesets added on release branch** (bug fixes during rc) are consumed together at finalize
|
||||
|
||||
@@ -2,62 +2,200 @@
|
||||
|
||||
**Object Content Addressable Store** — self-describing CAS with JSON Schema typed nodes.
|
||||
|
||||
## Overview
|
||||
Every node has a typed payload: its `type` field is the hash of a JSON Schema that describes the payload shape. Hashes are 13-character Crockford Base32 strings derived from XXH64 over deterministic CBOR encoding. Payloads can reference other nodes via `format: "cas_ref"` fields, forming a traversable DAG.
|
||||
|
||||
OCAS is a monorepo for storing and validating JSON data in a content-addressable store (CAS). Each node has a typed payload: its `type` field is the hash of a JSON Schema node that describes the payload shape. Hashes are 13-character Crockford Base32 strings derived from XXH64 over deterministic CBOR encoding.
|
||||
## Install
|
||||
|
||||
A bootstrap meta-schema is stored as a self-referencing seed node (`type === hash`). All other schemas are registered as nodes typed by that meta-schema. Payloads can reference other nodes via `format: "cas_ref"` fields; the library provides traversal, reference extraction, and integrity verification.
|
||||
```bash
|
||||
bun add -g @ocas/cli
|
||||
```
|
||||
|
||||
Use the in-memory store for tests and embedded apps, the filesystem store for persistence, and the CLI for local store management.
|
||||
The store is auto-created and bootstrapped on first use — no `init` command needed.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Register a schema (schemas are just nodes typed by the meta-schema)
|
||||
echo '{
|
||||
"type": "object",
|
||||
"properties": { "title": { "type": "string" }, "done": { "type": "boolean" } },
|
||||
"required": ["title", "done"],
|
||||
"additionalProperties": false
|
||||
}' | ocas put @ocas/schema -p
|
||||
# → { "type": "...", "value": "1ABC2DEF34567" }
|
||||
|
||||
# Give it a friendly name
|
||||
ocas var set @todo/schema 1ABC2DEF34567
|
||||
|
||||
# Store a todo item
|
||||
echo '{ "title": "Buy milk", "done": false }' | ocas put @todo/schema -p
|
||||
# → { "type": "...", "value": "9XYZ8WVU76543" }
|
||||
|
||||
# Retrieve it
|
||||
ocas get 9XYZ8WVU76543
|
||||
|
||||
# Verify integrity + schema validation
|
||||
ocas verify 9XYZ8WVU76543
|
||||
```
|
||||
|
||||
## Envelope Format
|
||||
|
||||
Every command outputs a `{ type, value }` JSON envelope. `type` is the hash of the result schema, `value` is the payload. This makes output self-describing and composable:
|
||||
|
||||
```bash
|
||||
# Pipe any command's output into render
|
||||
ocas put @ocas/schema schema.json | ocas render -p
|
||||
|
||||
# Or use the shorthand -r flag
|
||||
ocas get 9XYZ8WVU76543 -r
|
||||
|
||||
# Extract values with jq
|
||||
ocas list --type @ocas/schema | jq -r '.value[].hash'
|
||||
```
|
||||
|
||||
`render` is the only command that emits raw text instead of an envelope.
|
||||
|
||||
## Commands
|
||||
|
||||
### Store & Retrieve
|
||||
|
||||
```bash
|
||||
ocas put <type> <file> # store a node, returns its hash
|
||||
ocas put <type> -p # read payload from stdin
|
||||
ocas get <hash> # retrieve a node
|
||||
ocas has <hash> # check if a node exists
|
||||
ocas hash <type> <file> # compute hash without storing
|
||||
ocas verify <hash> # integrity check + schema validation
|
||||
```
|
||||
|
||||
### Graph Traversal
|
||||
|
||||
```bash
|
||||
ocas refs <hash> # list direct cas_ref edges
|
||||
ocas walk <hash> # recursive DAG traversal
|
||||
ocas walk <hash> --format tree # tree-view output
|
||||
```
|
||||
|
||||
### Listing & Querying
|
||||
|
||||
```bash
|
||||
ocas list --type <hash|name> # list nodes by type
|
||||
ocas list-schema # list all schemas
|
||||
ocas list-meta # list meta-schema hashes
|
||||
```
|
||||
|
||||
All list commands support sorting and pagination:
|
||||
|
||||
```bash
|
||||
ocas list --type todo/schema --sort updated --desc --limit 20
|
||||
ocas list --type todo/schema --offset 20 --limit 20 # page 2
|
||||
ocas list-schema --sort created --limit 50
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
Variables are mutable pointers to immutable data — like git branches pointing to commits.
|
||||
|
||||
Names must follow `@scope/name` format (e.g. `@myapp/config`). The `@ocas/*` scope is reserved.
|
||||
|
||||
```bash
|
||||
ocas var set @myapp/config <hash> # bind a name to a hash
|
||||
ocas var get @myapp/config # look up current binding
|
||||
ocas var delete @myapp/config # remove binding
|
||||
ocas var list [prefix] # list variables (prefix filter)
|
||||
ocas var list @myapp/ --tag env:prod # filter by scope prefix and tag
|
||||
ocas var history @myapp/config # show last 10 values (LRU)
|
||||
```
|
||||
|
||||
**Tags & labels** — attach metadata to variables:
|
||||
|
||||
```bash
|
||||
ocas var set @myapp/config <hash> --tag env:prod --tag pinned
|
||||
ocas var tag @myapp/config --schema <hash> status:active # add tag
|
||||
ocas var tag @myapp/config --schema <hash> :status # remove tag
|
||||
```
|
||||
|
||||
Any command that takes a hash also accepts a variable name:
|
||||
|
||||
```bash
|
||||
ocas get @myapp/config # resolves to the bound hash
|
||||
ocas put @ocas/schema s.json # @ocas/schema is a builtin variable
|
||||
```
|
||||
|
||||
### Templates & Rendering
|
||||
|
||||
Bind a [LiquidJS](https://liquidjs.com/) template to a schema, then render nodes of that type:
|
||||
|
||||
```bash
|
||||
# Set a template
|
||||
ocas template set <schema-hash> --inline "Todo: {{ payload.title }} [{{ payload.done }}]"
|
||||
|
||||
# Render a node (uses the template for its type, falls back to YAML)
|
||||
ocas render <hash>
|
||||
|
||||
# Render from a pipe (any envelope)
|
||||
ocas gc | ocas render -p
|
||||
|
||||
# Inline render shorthand
|
||||
ocas get <hash> -r
|
||||
```
|
||||
|
||||
Render options for recursive reference expansion:
|
||||
|
||||
```bash
|
||||
ocas render <hash> --resolution 3 # max recursion depth
|
||||
ocas render <hash> --decay 0.5 # depth decay factor
|
||||
ocas render <hash> --epsilon 0.01 # cutoff threshold
|
||||
```
|
||||
|
||||
### Garbage Collection
|
||||
|
||||
```bash
|
||||
ocas gc # collect unreachable nodes
|
||||
ocas gc | ocas render -p # human-readable stats
|
||||
```
|
||||
|
||||
Nodes reachable from any variable binding are kept; everything else is swept.
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
|
||||
| `--var-db <path>` | Variable database path |
|
||||
| `--json` | Compact single-line JSON output |
|
||||
| `--pipe`, `-p` | Read from stdin |
|
||||
| `--render`, `-r` | Render output inline |
|
||||
| `--sort created\|updated` | Sort key for list commands (default: `created`) |
|
||||
| `--limit <n>` | Max results (default: 100) |
|
||||
| `--offset <n>` | Skip first N results (default: 0) |
|
||||
| `--desc` | Sort descending |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌───────────┐
|
||||
│ @ocas/cli │
|
||||
│ @ocas/cli │ CLI interface
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ @ocas/fs │
|
||||
│ @ocas/fs │ Filesystem store (CBOR + SQLite)
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌────────────┐
|
||||
│ @ocas/core │
|
||||
│ @ocas/core │ Hashing, schemas, validation, bootstrap
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
| Layer | Package | Role |
|
||||
|-------|---------|------|
|
||||
| Core | `@ocas/core` | Hashing, schemas, stores, verify, bootstrap |
|
||||
| Storage | `@ocas/fs` | Filesystem-backed `Store` |
|
||||
| CLI | `@ocas/cli` | `ocas` command-line tool |
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description | Type |
|
||||
|---------|-------------|------|
|
||||
| [`@ocas/core`](packages/core/README.md) | Core CAS engine — hashing, schema, store, verify, bootstrap | lib |
|
||||
| [`@ocas/fs`](packages/fs/README.md) | Filesystem-backed CAS store | lib |
|
||||
| [`@ocas/cli`](packages/cli/README.md) | CLI tool (`ocas` binary) | cli |
|
||||
|
||||
## Quick Start
|
||||
## Using as a Library
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd ocas
|
||||
bun install --no-cache
|
||||
bun run build
|
||||
bun add @ocas/core # in-memory store
|
||||
bun add @ocas/core @ocas/fs # + filesystem persistence
|
||||
```
|
||||
|
||||
```typescript
|
||||
import {
|
||||
bootstrap,
|
||||
createMemoryStore,
|
||||
putSchema,
|
||||
validate,
|
||||
} from "@ocas/core";
|
||||
import { bootstrap, createMemoryStore, putSchema } from "@ocas/core";
|
||||
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
@@ -71,110 +209,32 @@ const typeHash = await putSchema(store, {
|
||||
|
||||
const hash = await store.put(typeHash, { message: "hello" });
|
||||
const node = store.get(hash);
|
||||
console.log(validate(store, node!)); // true
|
||||
```
|
||||
|
||||
For a persistent store:
|
||||
For filesystem persistence, use `@ocas/fs`:
|
||||
|
||||
```typescript
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { openStoreAndVarStore } from "@ocas/fs";
|
||||
|
||||
const store = createFsStore("/path/to/store");
|
||||
await bootstrap(store);
|
||||
const { store, varStore } = await openStoreAndVarStore("/path/to/store");
|
||||
```
|
||||
|
||||
Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli/README.md`](packages/cli/README.md)).
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Binary: `ocas` (from `@ocas/cli`). Default store: `~/.ocas`.
|
||||
|
||||
The store is auto-created and bootstrapped on first use — there is no `init`/`bootstrap`
|
||||
command, and schemas are ordinary `@schema`-typed nodes (`ocas put @schema file.json`),
|
||||
so there is no `schema` subcommand.
|
||||
|
||||
### Envelope format
|
||||
|
||||
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
|
||||
of the command's `@output/*` result schema and `value` is the command payload. This makes
|
||||
output self-describing and pipeable: feed any envelope into `render -p` to render its
|
||||
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
|
||||
raw (non-envelope) text.
|
||||
|
||||
```jsonc
|
||||
// ocas has <hash>
|
||||
{ "type": "AYHQD2YA9G667", "value": true }
|
||||
```
|
||||
|
||||
```
|
||||
Usage: ocas [--home <path>] [--json] <command> [args]
|
||||
|
||||
Commands (all emit a { type, value } envelope unless noted):
|
||||
put <type-hash> <file.json> Store node (value = hash) (@output/put)
|
||||
get <hash> Node payload + metadata (@output/get)
|
||||
has <hash> Existence boolean (@output/has)
|
||||
verify <hash> ok / corrupted / invalid (@output/verify)
|
||||
refs <hash> Direct cas_ref edges (@output/refs)
|
||||
walk <hash> [--format tree] Recursive traversal (@output/walk)
|
||||
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
|
||||
render <hash> [options] Render node as text (raw output)
|
||||
render --pipe/-p [options] Render a piped envelope (raw output)
|
||||
list --type <hash-or-name> Hashes for a type (value = list) (@output/list)
|
||||
list-meta Meta-schema hashes (@output/list-meta)
|
||||
list-schema All schema hashes (@output/list-schema)
|
||||
var set|get|delete|tag|list ... Variable CRUD (@output/var-*)
|
||||
template set|get|list|delete ... Output-template CRUD (@output/template-*)
|
||||
gc Garbage collection (@output/gc)
|
||||
|
||||
Flags:
|
||||
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
|
||||
--json Compact JSON output
|
||||
--pipe, -p Read a { type, value } envelope from stdin for render
|
||||
```
|
||||
|
||||
### Variable names
|
||||
|
||||
Any command that takes a hash also accepts a variable name. Builtin schemas
|
||||
(`@ocas/schema`, `@ocas/string`, `@ocas/object`, `@ocas/output/*`, …) are
|
||||
registered in the variable store during bootstrap; user variables created via
|
||||
`ocas var set <name> <hash>` resolve the same way. There is no separate alias
|
||||
concept — every name lookup queries the variable store.
|
||||
|
||||
### Pipe examples
|
||||
|
||||
```bash
|
||||
# Store a node, then render the stored content (the put envelope's hash is
|
||||
# a cas_ref, so render -p dereferences and renders it):
|
||||
ocas put @schema ./schemas/item.json | ocas render -p
|
||||
|
||||
# Render garbage-collection stats:
|
||||
ocas gc | ocas render -p
|
||||
|
||||
# List every schema, then consume the envelope's value array with jq:
|
||||
ocas list --type @schema | jq -r '.value[]'
|
||||
```
|
||||
See individual package READMEs for full API docs:
|
||||
[`@ocas/core`](packages/core/README.md) ·
|
||||
[`@ocas/fs`](packages/fs/README.md) ·
|
||||
[`@ocas/cli`](packages/cli/README.md)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun install --no-cache # install workspace dependencies
|
||||
bun run build # tsc --build (libs)
|
||||
bun run check # biome check
|
||||
bun run format # biome format --write
|
||||
bun test # run all package tests
|
||||
git clone <repo-url> && cd ocas
|
||||
bun install --no-cache
|
||||
bun run build # tsc --build
|
||||
bun test # run all tests
|
||||
bun run check # biome lint
|
||||
bun run format # biome format
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
Releases use [Changesets](https://github.com/changesets/changesets). From the repo root:
|
||||
|
||||
```bash
|
||||
bun run release # changeset version → build → publish to npm (@ocas/*)
|
||||
```
|
||||
|
||||
Individual packages block `prepublishOnly` and expect releases via the workspace `release` script.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
+24
-14
@@ -1,22 +1,32 @@
|
||||
{
|
||||
"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"
|
||||
"@shazhou/proman": "0.2.2",
|
||||
"@types/node": "^25.9.1",
|
||||
"tsx": "^4.22.4",
|
||||
"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": "vitest run",
|
||||
"check": "proman check",
|
||||
"format": "proman format"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/ocas.git"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/ocas",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"better-sqlite3"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+42
-54
@@ -1,63 +1,51 @@
|
||||
# @uncaged/cli-json-cas
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
|
||||
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
|
||||
- `openStore()` is now async and auto-bootstraps
|
||||
|
||||
### New Features
|
||||
|
||||
- 18 `@output/*` schemas registered at bootstrap
|
||||
- `list --type <hash-or-alias>` command (replaces `schema list`)
|
||||
- `verify` now checks both hash integrity and schema validation
|
||||
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
|
||||
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
|
||||
- Default LiquidJS templates for all output schemas
|
||||
- Pipe composition: any command output can be piped to `render -p`
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.6.0
|
||||
- @uncaged/json-cas-fs@0.6.0
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.5.3
|
||||
- @uncaged/json-cas-fs@0.5.3
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.3.0
|
||||
- @uncaged/json-cas-fs@0.3.0
|
||||
# @ocas/cli
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Patch Changes
|
||||
### Breaking Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.2.0
|
||||
- @uncaged/json-cas-fs@0.2.0
|
||||
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties
|
||||
- `bootstrap(store)` and `putSchema(store, schema)` are now **synchronous** (were async)
|
||||
- `VariableStore` class removed from `@ocas/core` — SQLite implementation moved to `@ocas/fs`
|
||||
- `createVariableStore()` removed from `@ocas/core`
|
||||
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`
|
||||
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally
|
||||
- `@ocas/core` has zero `bun:sqlite` imports — pure TypeScript
|
||||
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead
|
||||
|
||||
## 0.1.3
|
||||
### New Features
|
||||
|
||||
- `CasStore`, `VarStore`, `TagStore` sub-store types
|
||||
- `TagStore` — first-class tags on any CAS node (not just variables)
|
||||
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands
|
||||
- `ocas get` and `ocas var get` now include tag info in output
|
||||
- `ocas list --tag` and `ocas var list --tag` filter support
|
||||
- `var-store-helpers.ts` — shared validation/history logic
|
||||
- `validation.ts` — shared `validateName()` exported from core
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: replace workspace:^ with actual version numbers in published dependencies
|
||||
- Fix render output missing trailing newline.
|
||||
- Fix TypeScript LSP errors: tsconfig `noEmit`, `exactOptionalPropertyTypes` conditional spread, `schemaHash` scope.
|
||||
- Add agent skill setup hint with version to help output. Remove postinstall script (blocked by bun security policy). Update `ocas prompt setup` to guide cleanup of old skill versions before installing new ones.
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.1.3
|
||||
- @uncaged/json-cas-fs@0.1.3
|
||||
- Updated dependencies:
|
||||
- @ocas/core@0.1.2
|
||||
- @ocas/fs@0.1.2
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add `ocas prompt usage` and `ocas prompt setup` commands for agent skill management. Prompt content is bundled with the CLI and versioned with it.
|
||||
- Add `--version` flag to display CLI version.
|
||||
|
||||
- Updated dependencies:
|
||||
- @ocas/core@0.1.1
|
||||
- @ocas/fs@0.1.1
|
||||
|
||||
## 0.1.0
|
||||
|
||||
Initial release as `@ocas/cli`. CLI tool for OCAS with put, get, list, render, verify, gc, var, and template commands. Envelope output format with pipe composition support.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -86,8 +86,8 @@ raw, non-envelope text.
|
||||
### 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 +98,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 +108,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
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
{
|
||||
"name": "@ocas/cli",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ocas": "src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
|
||||
"ocas": "dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ocas/core": "workspace:*",
|
||||
"@ocas/fs": "workspace:*"
|
||||
"@ocas/core": "0.2.1",
|
||||
"@ocas/fs": "0.2.1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/ocas.git",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/cli",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# OCAS Skill Setup
|
||||
|
||||
You are being asked to install or update the OCAS (Object Content Addressable Store) skill
|
||||
so that you know how to use the `ocas` CLI.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Check if OCAS CLI is installed:**
|
||||
```bash
|
||||
ocas --help
|
||||
```
|
||||
If not installed: `bun add -g @ocas/cli`
|
||||
|
||||
2. **Clean up old versions of the skill** (if any exist):
|
||||
- Look for any existing OCAS/ocas skill files in your skill directories
|
||||
(e.g. `~/.hermes/skills/ocas/SKILL.md` or similar paths).
|
||||
- If a skill exists, read its `version` from the YAML frontmatter.
|
||||
- If the version matches the current CLI version (`ocas --version`), you're done — no update needed.
|
||||
- If the version is older, **delete the old skill file** before proceeding.
|
||||
|
||||
3. **Get the usage skill content:**
|
||||
```bash
|
||||
ocas prompt usage
|
||||
```
|
||||
|
||||
4. **Create a SKILL.md file** in your skill directory (e.g. `~/.hermes/skills/ocas/SKILL.md`).
|
||||
The output of `ocas prompt usage` is the skill body WITHOUT frontmatter.
|
||||
You must prepend the following YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: ocas
|
||||
description: "Use when storing, retrieving, or managing typed JSON data with OCAS (Object Content Addressable Store). Covers CLI usage, variable management, templates, rendering, and pipe composition."
|
||||
version: <VERSION>
|
||||
author: OCAS
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [cas, storage, cli, json, schema, content-addressing]
|
||||
---
|
||||
```
|
||||
|
||||
Replace `<VERSION>` with the installed CLI version (from `ocas --version`).
|
||||
|
||||
5. **Verify** the skill is loadable by your agent framework.
|
||||
|
||||
## Notes
|
||||
|
||||
- The skill content is bundled with the CLI and versioned with it — always use
|
||||
`ocas prompt usage` to get the content matching your installed version.
|
||||
- Do NOT hand-edit the skill body. If the CLI is updated, re-run `ocas prompt setup`
|
||||
and follow the steps again.
|
||||
- When upgrading, always delete the old skill first to avoid stale instructions.
|
||||
@@ -0,0 +1,198 @@
|
||||
# OCAS — Object Content Addressable Store
|
||||
|
||||
## Overview
|
||||
|
||||
OCAS is a self-describing content-addressable store for typed JSON data. Every node has a `type` field (hash of a JSON Schema) and a `payload`. Hashes are 13-character Crockford Base32 strings (XXH64 over deterministic CBOR).
|
||||
|
||||
All commands output `{ type, value }` JSON envelopes, making them composable via pipes.
|
||||
|
||||
**Install:** `bun add -g @ocas/cli`
|
||||
|
||||
**Packages:** `@ocas/core` (engine) · `@ocas/fs` (filesystem store) · `@ocas/cli` (CLI)
|
||||
|
||||
## When to Use
|
||||
|
||||
- Storing structured, schema-validated JSON data with content addressing
|
||||
- Building knowledge graphs or DAGs with typed nodes and `cas_ref` edges
|
||||
- Agent memory, config versioning, or any use case needing immutable data + mutable pointers
|
||||
- Don't use for: binary blobs, large files, or high-throughput streaming
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Register a schema (from stdin)
|
||||
echo '{
|
||||
"type": "object",
|
||||
"properties": { "title": { "type": "string" }, "done": { "type": "boolean" } },
|
||||
"required": ["title", "done"],
|
||||
"additionalProperties": false
|
||||
}' | ocas put @ocas/schema -p
|
||||
# → { "type": "...", "value": "<schema-hash>" }
|
||||
|
||||
# Name it
|
||||
ocas var set @todo/schema <schema-hash>
|
||||
|
||||
# Store data
|
||||
echo '{ "title": "Buy milk", "done": false }' | ocas put @todo/schema -p
|
||||
|
||||
# Retrieve + verify
|
||||
ocas get <hash>
|
||||
ocas verify <hash>
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Hashes
|
||||
13-char uppercase Crockford Base32 (e.g. `9S7JEYS3FKSDH`). Deterministic: same content → same hash.
|
||||
|
||||
### Envelope Format
|
||||
Every command outputs `{ type, value }`. `type` is the hash of the result schema. Pipe any envelope into `render -p` to render it human-readable.
|
||||
|
||||
### Variables
|
||||
Mutable pointers to immutable data (like git branches → commits). All names must follow `@scope/name` format:
|
||||
- `@myapp/config` ✅
|
||||
- `@ocas/schema` ✅ (builtin, read-only)
|
||||
- `config` ❌ (no scope)
|
||||
|
||||
### Templates
|
||||
LiquidJS templates bound to a schema. `render` uses the template for the node's type, falling back to YAML.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### Store & Retrieve
|
||||
|
||||
```bash
|
||||
ocas put <type> <file> # store node → hash
|
||||
ocas put <type> -p # read payload from stdin
|
||||
ocas get <hash> # retrieve node
|
||||
ocas has <hash> # check existence
|
||||
ocas hash <type> <file> # compute hash without storing
|
||||
ocas verify <hash> # integrity + schema validation
|
||||
```
|
||||
|
||||
### Graph Traversal
|
||||
|
||||
```bash
|
||||
ocas refs <hash> # direct cas_ref edges
|
||||
ocas walk <hash> # recursive DAG traversal
|
||||
ocas walk <hash> --format tree # tree view
|
||||
```
|
||||
|
||||
### Listing & Querying
|
||||
|
||||
```bash
|
||||
ocas list --type <hash|name> # list nodes by type
|
||||
ocas list-schema # all schemas
|
||||
ocas list-meta # meta-schema hashes
|
||||
```
|
||||
|
||||
Sorting and pagination:
|
||||
|
||||
```bash
|
||||
ocas list --type @todo/schema --sort updated --desc --limit 20
|
||||
ocas list --type @todo/schema --offset 20 --limit 20 # page 2
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
```bash
|
||||
ocas var set @myapp/config <hash> # bind name → hash
|
||||
ocas var set @myapp/config <hash> --tag env:prod --tag pinned
|
||||
ocas var get @myapp/config # look up
|
||||
ocas var delete @myapp/config # remove
|
||||
ocas var list [prefix] # list (prefix filter)
|
||||
ocas var list @myapp/ --tag env:prod # filter by scope + tag
|
||||
ocas var history @myapp/config # last 10 values (LRU)
|
||||
ocas 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
|
||||
```
|
||||
|
||||
### Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
|
||||
| `--var-db <path>` | Variable database path |
|
||||
| `--json` | Compact JSON output |
|
||||
| `-p`, `--pipe` | Read from stdin |
|
||||
| `-r`, `--render` | Render output inline |
|
||||
| `--sort created\|updated` | Sort key (default: `created`) |
|
||||
| `--limit <n>` | Max results (default: 100) |
|
||||
| `--offset <n>` | Skip first N (default: 0) |
|
||||
| `--desc` | Sort descending |
|
||||
|
||||
## Pipe Composition Patterns
|
||||
|
||||
```bash
|
||||
# Store + render in one go
|
||||
echo '{"title":"test","done":false}' | ocas put @todo/schema -p | ocas render -p
|
||||
|
||||
# Or use -r shorthand
|
||||
ocas get <hash> -r
|
||||
|
||||
# List schemas, extract hashes with jq
|
||||
ocas list --type @ocas/schema | jq -r '.value[].hash'
|
||||
|
||||
# Render GC stats
|
||||
ocas gc | ocas render -p
|
||||
```
|
||||
|
||||
## Library Usage
|
||||
|
||||
```typescript
|
||||
import { bootstrap, createMemoryStore, putSchema } from "@ocas/core";
|
||||
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const typeHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { message: { type: "string" } },
|
||||
required: ["message"],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const hash = await store.put(typeHash, { message: "hello" });
|
||||
```
|
||||
|
||||
For filesystem persistence:
|
||||
|
||||
```typescript
|
||||
import { 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.
|
||||
+479
-452
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,21 @@
|
||||
// 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,
|
||||
"name": "Alice",
|
||||
},
|
||||
"type": "FRBAB1BF0ZBCS",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
|
||||
{
|
||||
"type": "7V5G8E2VW8B2G",
|
||||
"value": {
|
||||
"payload": {
|
||||
"age": 30,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// 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.2 render --resolution with different value 1`] = `"Hello Alice!"`;
|
||||
|
||||
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// 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,4 +1,27 @@
|
||||
// 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`] = `
|
||||
{
|
||||
"type": "52HEFB52BD0GF",
|
||||
"value": "ok",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core > 1.10 refs lists direct references (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "2TKP4RGBJ4V43",
|
||||
"value": []
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core > 1.11 walk shows traversal tree (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "4HG6MD3XG5H5C",
|
||||
"value": [
|
||||
"9W3MGR3184QYE"
|
||||
]
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -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,18 @@ afterEach(() => {
|
||||
/**
|
||||
* Run CLI command and return stdout, stderr, and exit code
|
||||
*/
|
||||
async function runCliAlias(...args: string[]): Promise<{
|
||||
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,
|
||||
};
|
||||
function runCliAlias(...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 };
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the `value` field from a { type, value } envelope JSON string. */
|
||||
@@ -67,7 +55,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 +73,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 +89,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 +121,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 +156,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 { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { 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,25 @@ 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,
|
||||
});
|
||||
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 +69,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 +124,20 @@ 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 +145,25 @@ 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,
|
||||
});
|
||||
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 +178,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 }));
|
||||
@@ -210,7 +194,7 @@ describe("Phase 3: Variable System", () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"set",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
nodeHash,
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
@@ -221,7 +205,7 @@ describe("Phase 3: Variable System", () => {
|
||||
const { stdout, exitCode } = await runCli([
|
||||
"var",
|
||||
"get",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
]);
|
||||
@@ -233,15 +217,19 @@ 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();
|
||||
expect(stdout).toContain("myapp/config");
|
||||
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");
|
||||
});
|
||||
|
||||
test("3.4 var list prefix filters by prefix", async () => {
|
||||
const { stdout, exitCode } = await runCli(["var", "list", "myapp/"]);
|
||||
const { stdout, exitCode } = await runCli(["var", "list", "@myapp/"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
expect(stdout).toContain("myapp/config");
|
||||
expect(stdout).toContain("@myapp/config");
|
||||
});
|
||||
|
||||
test("3.5 var set upsert updates existing variable", async () => {
|
||||
@@ -252,23 +240,24 @@ describe("Phase 3: Variable System", () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"set",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
node2Hash,
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
// Restore original value
|
||||
await runCli(["var", "set", "myapp/config", nodeHash]);
|
||||
await runCli(["var", "set", "@myapp/config", nodeHash]);
|
||||
});
|
||||
|
||||
test("3.6 var tag adds kv tag and label", async () => {
|
||||
test("3.6 var set with tag and label adds them", async () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"tag",
|
||||
"myapp/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
"set",
|
||||
"@myapp/config",
|
||||
nodeHash,
|
||||
"--tag",
|
||||
"env:prod",
|
||||
"--tag",
|
||||
"important",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
@@ -283,7 +272,7 @@ describe("Phase 3: Variable System", () => {
|
||||
"env:prod",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("myapp/config");
|
||||
expect(stdout).toContain("@myapp/config");
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -295,18 +284,18 @@ describe("Phase 3: Variable System", () => {
|
||||
"important",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("myapp/config");
|
||||
expect(stdout).toContain("@myapp/config");
|
||||
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",
|
||||
"myapp/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
":important",
|
||||
"set",
|
||||
"@myapp/config",
|
||||
nodeHash,
|
||||
"--tag",
|
||||
"env:prod",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
@@ -317,14 +306,14 @@ describe("Phase 3: Variable System", () => {
|
||||
"--tag",
|
||||
"important",
|
||||
]);
|
||||
expect(listOut).not.toContain("myapp/config");
|
||||
expect(listOut).not.toContain("@myapp/config");
|
||||
});
|
||||
|
||||
test("3.10 var delete removes variable", async () => {
|
||||
const { exitCode, stdout } = await runCli([
|
||||
"var",
|
||||
"delete",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
@@ -334,7 +323,7 @@ describe("Phase 3: Variable System", () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"var",
|
||||
"get",
|
||||
"myapp/config",
|
||||
"@myapp/config",
|
||||
"--schema",
|
||||
typeHash,
|
||||
]);
|
||||
@@ -347,25 +336,24 @@ 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,
|
||||
});
|
||||
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 +368,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(() => {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { 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 }));
|
||||
@@ -39,24 +35,25 @@ beforeAll(async () => {
|
||||
nodeHash = envValue(stdout) as string;
|
||||
|
||||
// Set a var referencing the node so it survives GC
|
||||
await runCli(["var", "set", "gc-test/ref", nodeHash]);
|
||||
await runCli(["var", "set", "@test/gc-test/ref", nodeHash]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
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 +73,15 @@ 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:");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash } from "@ocas/core";
|
||||
import { bootstrap, validate } from "@ocas/core";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { JSONSchema } from "@ocas/core";
|
||||
@@ -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,7 +43,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -50,40 +51,42 @@ export async function putSchemaFile(
|
||||
* 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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -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,23 +28,27 @@ 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,
|
||||
);
|
||||
const schemaList = envValue(schemaListOut) as string[];
|
||||
const schemaList = envValue(schemaListOut) as Array<{ hash: string }>;
|
||||
expect(Array.isArray(schemaList)).toBe(true);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(["list-meta"], storePath);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
|
||||
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
type: string;
|
||||
value: Array<{ hash: string; created: number; updated: number }>;
|
||||
};
|
||||
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
expect(Array.isArray(parsed.value)).toBe(true);
|
||||
expect(parsed.value).toHaveLength(1);
|
||||
expect(parsed.value[0]).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
const first = parsed.value[0] as { hash: string };
|
||||
expect(first.hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("E1. --json flag yields compact JSON", async () => {
|
||||
@@ -115,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
|
||||
@@ -136,10 +140,12 @@ describe("E4. list-schema vs list --type with multiple meta-schema versions", ()
|
||||
storePath,
|
||||
);
|
||||
expect(lsCode).toBe(0);
|
||||
const lsValue = envValue(lsOut) as string[];
|
||||
expect(lsValue).toContain(sM1);
|
||||
expect(lsValue).toContain(m1);
|
||||
expect(lsValue).toContain(m2);
|
||||
const lsHashes = (envValue(lsOut) as Array<{ hash: string }>).map(
|
||||
(e) => e.hash,
|
||||
);
|
||||
expect(lsHashes).toContain(sM1);
|
||||
expect(lsHashes).toContain(m1);
|
||||
expect(lsHashes).toContain(m2);
|
||||
|
||||
// CLI: list --type <M2 hash> must NOT include sM1
|
||||
const { stdout: ltOut, exitCode: ltCode } = await runCli(
|
||||
@@ -147,10 +153,12 @@ describe("E4. list-schema vs list --type with multiple meta-schema versions", ()
|
||||
storePath,
|
||||
);
|
||||
expect(ltCode).toBe(0);
|
||||
const ltValue = envValue(ltOut) as string[];
|
||||
expect(ltValue).not.toContain(sM1);
|
||||
const ltHashes = (envValue(ltOut) as Array<{ hash: string }>).map(
|
||||
(e) => e.hash,
|
||||
);
|
||||
expect(ltHashes).not.toContain(sM1);
|
||||
|
||||
// list-schema.value.length > list --type <newest>.value.length
|
||||
expect(lsValue.length).toBeGreaterThan(ltValue.length);
|
||||
expect(lsHashes.length).toBeGreaterThan(ltHashes.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { envValue, runCli } from "./helpers.js";
|
||||
|
||||
const HASH_RE = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||
|
||||
let storePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
storePath = mkdtempSync(join(tmpdir(), "ocas-list-pagination-"));
|
||||
mkdirSync(storePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(storePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function putString(text: string): Promise<string> {
|
||||
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,
|
||||
});
|
||||
return (JSON.parse(out) as { value: string }).value;
|
||||
}
|
||||
|
||||
async function makeN(n: number, gap = 2): Promise<string[]> {
|
||||
const hashes: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
hashes.push(await putString(`val-${i}-${Math.random()}`));
|
||||
if (gap > 0 && i < n - 1) {
|
||||
await new Promise((r) => setTimeout(r, gap));
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
describe("CLI list --type pagination", () => {
|
||||
test("E1. entries are {hash, created, updated} objects", async () => {
|
||||
await makeN(3);
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["list", "--type", "@ocas/string"],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const value = envValue(stdout) as Array<{
|
||||
hash: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}>;
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
for (const e of value) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
expect(typeof e.updated).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("E2. --limit 2", async () => {
|
||||
await makeN(5, 0);
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--limit", "2"],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const value = envValue(stdout) as unknown[];
|
||||
expect(value).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("E3. --offset 1 skips first", async () => {
|
||||
await makeN(3);
|
||||
const { stdout: a } = await runCli(
|
||||
["list", "--type", "@ocas/string"],
|
||||
storePath,
|
||||
);
|
||||
const { stdout: b } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--offset", "1", "--limit", "100"],
|
||||
storePath,
|
||||
);
|
||||
const all = envValue(a) as Array<{ hash: string }>;
|
||||
const skip = envValue(b) as Array<{ hash: string }>;
|
||||
expect(skip).toHaveLength(all.length - 1);
|
||||
expect((skip[0] as { hash: string }).hash).toBe(
|
||||
(all[1] as { hash: string }).hash,
|
||||
);
|
||||
});
|
||||
|
||||
test("E4. --desc reverses default order", async () => {
|
||||
await makeN(3);
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--desc"],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
const value = envValue(stdout) as Array<{ created: number }>;
|
||||
for (let i = 1; i < value.length; i++) {
|
||||
expect((value[i] as { created: number }).created).toBeLessThanOrEqual(
|
||||
(value[i - 1] as { created: number }).created,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("E5. --sort updated accepted; equals created for CAS", async () => {
|
||||
await makeN(3);
|
||||
const { stdout: a } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--sort", "created"],
|
||||
storePath,
|
||||
);
|
||||
const { stdout: b } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--sort", "updated"],
|
||||
storePath,
|
||||
);
|
||||
expect(envValue(a)).toEqual(envValue(b));
|
||||
});
|
||||
|
||||
test("E7. invalid --sort exits non-zero", async () => {
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["list", "--type", "@ocas/string", "--sort", "foo"],
|
||||
storePath,
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("--sort");
|
||||
});
|
||||
|
||||
test("E8. invalid --limit exits non-zero", async () => {
|
||||
const r1 = await runCli(
|
||||
["list", "--type", "@ocas/string", "--limit", "-1"],
|
||||
storePath,
|
||||
);
|
||||
expect(r1.exitCode).not.toBe(0);
|
||||
expect(r1.stderr).toContain("--limit");
|
||||
|
||||
const r2 = await runCli(
|
||||
["list", "--type", "@ocas/string", "--limit", "abc"],
|
||||
storePath,
|
||||
);
|
||||
expect(r2.exitCode).not.toBe(0);
|
||||
expect(r2.stderr).toContain("--limit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI list-meta / list-schema pagination", () => {
|
||||
test("F1. list-meta entries are objects", async () => {
|
||||
// Bootstrap implicitly happens when running any cli command that opens store
|
||||
await runCli(["list-meta"], storePath);
|
||||
const { stdout } = await runCli(["list-meta"], storePath);
|
||||
const value = envValue(stdout) as Array<{
|
||||
hash: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}>;
|
||||
expect(value.length).toBeGreaterThanOrEqual(1);
|
||||
for (const e of value) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("F2. list-schema --limit honored", async () => {
|
||||
const { stdout } = await runCli(["list-schema", "--limit", "3"], storePath);
|
||||
const value = envValue(stdout) as unknown[];
|
||||
expect(value).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("F3. list-schema --desc reverses order", async () => {
|
||||
const { stdout: asc } = await runCli(["list-schema"], storePath);
|
||||
const { stdout: desc } = await runCli(["list-schema", "--desc"], storePath);
|
||||
const a = envValue(asc) as Array<{ hash: string }>;
|
||||
const d = envValue(desc) as Array<{ hash: string }>;
|
||||
expect(d[0]?.hash).toBe(a[a.length - 1]?.hash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI var list pagination", () => {
|
||||
test("G1./G2. --limit and --offset on var list", async () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const h = await putString(`tval-${i}`);
|
||||
await runCli(["var", "set", `@test/myvar-${i}`, h], storePath);
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
const { stdout: lim } = await runCli(
|
||||
["var", "list", "@test/myvar-", "--limit", "2"],
|
||||
storePath,
|
||||
);
|
||||
expect((envValue(lim) as unknown[]).length).toBe(2);
|
||||
|
||||
const { stdout: off } = await runCli(
|
||||
["var", "list", "@test/myvar-", "--offset", "1", "--limit", "10"],
|
||||
storePath,
|
||||
);
|
||||
const offList = envValue(off) as Array<{ name: string }>;
|
||||
expect(offList.length).toBe(3);
|
||||
expect(offList[0]?.name).toBe("@test/myvar-1");
|
||||
});
|
||||
|
||||
test("G3. --desc reverses var list order", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const h = await putString(`dval-${i}`);
|
||||
await runCli(["var", "set", `@test/dv-${i}`, h], storePath);
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
const { stdout } = await runCli(
|
||||
["var", "list", "@test/dv-", "--desc"],
|
||||
storePath,
|
||||
);
|
||||
const list = envValue(stdout) as Array<{ name: string }>;
|
||||
expect(list[0]?.name).toBe("@test/dv-2");
|
||||
});
|
||||
|
||||
test("G4. --sort updated", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const h = await putString(`sval-${i}`);
|
||||
await runCli(["var", "set", `@test/sv-${i}`, h], storePath);
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
// Re-set sv-0 with NEW value to bump updated
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
const newH = await putString("sval-0-new");
|
||||
await runCli(["var", "set", "@test/sv-0", newH], storePath);
|
||||
|
||||
const { stdout } = await runCli(
|
||||
["var", "list", "@test/sv-", "--sort", "updated"],
|
||||
storePath,
|
||||
);
|
||||
const list = envValue(stdout) as Array<{ name: string }>;
|
||||
expect(list[list.length - 1]?.name).toBe("@test/sv-0");
|
||||
});
|
||||
|
||||
test("G6. invalid --sort exits non-zero", async () => {
|
||||
const r = await runCli(["var", "list", "--sort", "bogus"], storePath);
|
||||
expect(r.exitCode).not.toBe(0);
|
||||
expect(r.stderr).toContain("--sort");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
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 { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { envValue, stripVolatile } from "./helpers";
|
||||
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,35 @@ 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 +99,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",
|
||||
@@ -109,15 +107,15 @@ describe("Phase 8: Pipe Composition", () => {
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Downstream consumers (jq, etc.) read the `value` array of hashes.
|
||||
const value = envValue(stdout) as string[];
|
||||
// Downstream consumers (jq, etc.) read the `value` array of {hash,...}.
|
||||
const value = envValue(stdout) as Array<{ hash: string }>;
|
||||
expect(Array.isArray(value)).toBe(true);
|
||||
for (const hash of value) {
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
for (const entry of value) {
|
||||
expect(entry.hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
});
|
||||
|
||||
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 { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { 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,23 @@ afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runCli(
|
||||
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 };
|
||||
): { 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}$/);
|
||||
});
|
||||
|
||||
@@ -96,6 +94,7 @@ describe("Phase 1: CAS Core", () => {
|
||||
test("1.13 list --type returns nodes of that type", async () => {
|
||||
const { stdout, exitCode } = await runCli(["list", "--type", typeHash]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(envValue(stdout)).toContain(nodeHash);
|
||||
const value = envValue(stdout) as Array<{ hash: string }>;
|
||||
expect(value.map((e) => e.hash)).toContain(nodeHash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
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,42 @@ 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 +105,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 +196,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 +319,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 +419,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 +454,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,5 +1,6 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { envValue, putSchemaFile, runCli } from "./helpers";
|
||||
@@ -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,15 @@ 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 +596,18 @@ 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,
|
||||
}).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 +616,17 @@ 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,
|
||||
});
|
||||
} 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,229 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash } from "@ocas/core";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
|
||||
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 { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
|
||||
// ---- 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,25 @@ afterEach(() => {
|
||||
/**
|
||||
* Run CLI command and return stdout, stderr, and exit code
|
||||
*/
|
||||
async function runCli(...args: string[]): Promise<{
|
||||
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,
|
||||
};
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +64,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 +87,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 +107,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 +136,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 +166,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 +186,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 +206,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 +224,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 +234,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 +256,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 +276,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 +287,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 +301,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 +315,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 +338,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 +374,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 +394,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 +411,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 +440,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 +451,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 +474,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 +498,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 +523,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 +570,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 +584,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 { describe, expect, test } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
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 { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
|
||||
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,30 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
async function runCli(...args: string[]): Promise<{
|
||||
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,
|
||||
};
|
||||
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 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 };
|
||||
}
|
||||
@@ -87,13 +64,13 @@ describe("var history", () => {
|
||||
const { schema, values } = await setupSchemaAndValues();
|
||||
const v1 = values[0] as Hash;
|
||||
|
||||
let r = await runCli("var", "set", "x", v1);
|
||||
let r = await runCli("var", "set", "@test/x", v1);
|
||||
expect(r.exitCode).toBe(0);
|
||||
|
||||
r = await runCli("var", "history", "x", "--schema", schema);
|
||||
r = await runCli("var", "history", "@test/x", "--schema", schema);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
expect(envelope.value.name).toBe("x");
|
||||
expect(envelope.value.name).toBe("@test/x");
|
||||
expect(envelope.value.schema).toBe(schema);
|
||||
expect(envelope.value.values).toEqual([v1]);
|
||||
});
|
||||
@@ -102,11 +79,11 @@ describe("var history", () => {
|
||||
const { schema, values } = await setupSchemaAndValues();
|
||||
const [v1, v2, v3] = values as [Hash, Hash, Hash, Hash];
|
||||
|
||||
await runCli("var", "set", "x", v1);
|
||||
await runCli("var", "set", "x", v2);
|
||||
await runCli("var", "set", "x", v3);
|
||||
await runCli("var", "set", "@test/x", v1);
|
||||
await runCli("var", "set", "@test/x", v2);
|
||||
await runCli("var", "set", "@test/x", v3);
|
||||
|
||||
const r = await runCli("var", "history", "x", "--schema", schema);
|
||||
const r = await runCli("var", "history", "@test/x", "--schema", schema);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
expect(envelope.value.values).toEqual([v3, v2, v1]);
|
||||
@@ -116,10 +93,10 @@ describe("var history", () => {
|
||||
const { schema: _schema, values } = await setupSchemaAndValues();
|
||||
const [v1, v2] = values as [Hash, Hash, Hash, Hash];
|
||||
|
||||
await runCli("var", "set", "x", v1);
|
||||
await runCli("var", "set", "x", v2);
|
||||
await runCli("var", "set", "@test/x", v1);
|
||||
await runCli("var", "set", "@test/x", v2);
|
||||
|
||||
const r = await runCli("var", "history", "x");
|
||||
const r = await runCli("var", "history", "@test/x");
|
||||
expect(r.exitCode).toBe(0);
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
expect(envelope.value.values).toEqual([v2, v1]);
|
||||
@@ -131,46 +108,3 @@ describe("var history", () => {
|
||||
expect(r.stderr).toContain("Variable not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("var rollback", () => {
|
||||
test("rolls back to a previous value", async () => {
|
||||
const { schema, values } = await setupSchemaAndValues();
|
||||
const [v1, v2, v3] = values as [Hash, Hash, Hash, Hash];
|
||||
|
||||
await runCli("var", "set", "x", v1);
|
||||
await runCli("var", "set", "x", v2);
|
||||
await runCli("var", "set", "x", v3);
|
||||
|
||||
// History: [v3, v2, v1] — rollback to position 2 (v1)
|
||||
const r = await runCli("var", "rollback", "x", "2", "--schema", schema);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
expect(envelope.value.value).toBe(v1);
|
||||
|
||||
// After rollback, history is [v1, v3, v2]
|
||||
const h = await runCli("var", "history", "x", "--schema", schema);
|
||||
expect(JSON.parse(h.stdout).value.values).toEqual([v1, v3, v2]);
|
||||
});
|
||||
|
||||
test("rollback fails for invalid position", async () => {
|
||||
const { schema: _schema, values } = await setupSchemaAndValues();
|
||||
const v1 = values[0] as Hash;
|
||||
await runCli("var", "set", "x", v1);
|
||||
|
||||
const r = await runCli("var", "rollback", "x", "9");
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toContain("not found");
|
||||
});
|
||||
|
||||
test("rollback fails for negative position", async () => {
|
||||
const r = await runCli("var", "rollback", "x", "-1");
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toContain("Invalid position");
|
||||
});
|
||||
|
||||
test("rollback rejects @ocas/ namespace", async () => {
|
||||
const r = await runCli("var", "rollback", "@ocas/string", "1");
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toContain("@ocas/");
|
||||
});
|
||||
});
|
||||
|
||||
+180
-414
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,18 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { 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,19 @@ afterAll(() => {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runCli(
|
||||
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 };
|
||||
): { 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,10 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../fs" }
|
||||
]
|
||||
}
|
||||
|
||||
+35
-42
@@ -1,47 +1,40 @@
|
||||
# @uncaged/json-cas
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
|
||||
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
|
||||
- `openStore()` is now async and auto-bootstraps
|
||||
|
||||
### New Features
|
||||
|
||||
- 18 `@output/*` schemas registered at bootstrap
|
||||
- `list --type <hash-or-alias>` command (replaces `schema list`)
|
||||
- `verify` now checks both hash integrity and schema validation
|
||||
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
|
||||
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
|
||||
- Default LiquidJS templates for all output schemas
|
||||
- Pipe composition: any command output can be piped to `render -p`
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feat: add oneOf support to meta-schema validation
|
||||
|
||||
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.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
|
||||
# @ocas/core
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
### Breaking Changes
|
||||
|
||||
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
|
||||
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties
|
||||
- `bootstrap(store)` and `putSchema(store, schema)` are now **synchronous** (were async)
|
||||
- `VariableStore` class removed from `@ocas/core` — SQLite implementation moved to `@ocas/fs`
|
||||
- `createVariableStore()` removed from `@ocas/core`
|
||||
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`
|
||||
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally
|
||||
- `@ocas/core` has zero `bun:sqlite` imports — pure TypeScript
|
||||
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead
|
||||
|
||||
## 0.1.3
|
||||
### New Features
|
||||
|
||||
- `CasStore`, `VarStore`, `TagStore` sub-store types
|
||||
- `TagStore` — first-class tags on any CAS node (not just variables)
|
||||
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands
|
||||
- `ocas get` and `ocas var get` now include tag info in output
|
||||
- `ocas list --tag` and `ocas var list --tag` filter support
|
||||
- `var-store-helpers.ts` — shared validation/history logic
|
||||
- `validation.ts` — shared `validateName()` exported from core
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix TypeScript LSP errors: tsconfig `noEmit`, `exactOptionalPropertyTypes` conditional spread, `schemaHash` scope.
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Internal improvements for v0.1.1 release.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
Initial release as `@ocas/core`. Content-addressable store engine with JSON Schema typed nodes, XXH64 hashing, variable store, render with resolution decay, and LiquidJS template support.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ocas/core",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -14,14 +14,22 @@
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
"liquidjs": "^10.27.0",
|
||||
"xxhash-wasm": "^1.1.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/ocas.git",
|
||||
"directory": "packages/core"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/core",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
},
|
||||
"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,10 +18,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/var-rollback",
|
||||
"@ocas/output/tag",
|
||||
"@ocas/output/untag",
|
||||
"@ocas/output/template-set",
|
||||
"@ocas/output/template-get",
|
||||
"@ocas/output/template-list",
|
||||
@@ -36,7 +36,7 @@ const OUTPUT_ALIASES = [
|
||||
describe("bootstrap - Built-in Schemas", () => {
|
||||
test("should return map of 31 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 + 22 output aliases = 31
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/schema");
|
||||
@@ -62,12 +62,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();
|
||||
@@ -75,45 +75,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" });
|
||||
@@ -121,7 +121,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");
|
||||
@@ -132,8 +132,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);
|
||||
|
||||
@@ -148,15 +148,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);
|
||||
}
|
||||
@@ -170,7 +170,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];
|
||||
@@ -185,7 +185,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");
|
||||
|
||||
@@ -199,7 +199,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");
|
||||
|
||||
@@ -215,7 +215,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");
|
||||
|
||||
@@ -227,7 +227,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");
|
||||
|
||||
@@ -241,7 +241,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");
|
||||
|
||||
@@ -254,7 +254,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");
|
||||
|
||||
@@ -271,7 +271,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");
|
||||
|
||||
@@ -287,7 +287,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");
|
||||
|
||||
@@ -303,7 +303,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");
|
||||
|
||||
@@ -316,7 +316,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);
|
||||
@@ -327,15 +327,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()).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();
|
||||
const aliases = bootstrap(store);
|
||||
const schemas = store.cas.listSchemas().map((e) => e.hash);
|
||||
|
||||
for (const [, hash] of Object.entries(aliases)) {
|
||||
expect(schemas).toContain(hash);
|
||||
|
||||
+101
-41
@@ -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",
|
||||
},
|
||||
@@ -169,7 +180,15 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
"@ocas/output/list",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", format: "ocas_ref" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
},
|
||||
required: ["hash", "created", "updated"],
|
||||
},
|
||||
title: "ocas list result",
|
||||
},
|
||||
],
|
||||
@@ -177,7 +196,15 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
"@ocas/output/list-meta",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", format: "ocas_ref" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
},
|
||||
required: ["hash", "created", "updated"],
|
||||
},
|
||||
title: "ocas list-meta result",
|
||||
},
|
||||
],
|
||||
@@ -185,7 +212,15 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
"@ocas/output/list-schema",
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", format: "ocas_ref" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
},
|
||||
required: ["hash", "created", "updated"],
|
||||
},
|
||||
title: "ocas list-schema result",
|
||||
},
|
||||
],
|
||||
@@ -201,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",
|
||||
},
|
||||
],
|
||||
@@ -213,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",
|
||||
{
|
||||
@@ -245,11 +286,35 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ocas/output/var-rollback",
|
||||
"@ocas/output/tag",
|
||||
{
|
||||
type: "object",
|
||||
properties: { ...VARIABLE_PROPERTIES },
|
||||
title: "ocas var rollback result",
|
||||
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",
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -310,29 +375,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> = {
|
||||
@@ -348,16 +410,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,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";
|
||||
}
|
||||
}
|
||||
+69
-127
@@ -1,179 +1,121 @@
|
||||
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("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("config", hashA);
|
||||
varStore.set("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("config", hashRef);
|
||||
varStore.remove("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("uwf.thread", hash1);
|
||||
varStore.set("uwf.workflow", hash2);
|
||||
varStore.set("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("var1", hashA1);
|
||||
varStore.set("var2", hashA2);
|
||||
varStore.set("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("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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -16,9 +15,10 @@ export interface GcStats {
|
||||
* - Sweep: delete unmarked nodes
|
||||
* - Schema preservation: schemas of reachable nodes are also marked
|
||||
*/
|
||||
export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
// Get all variables (no filters → global)
|
||||
const variables = varStore.list();
|
||||
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 = store.var.list();
|
||||
const scanned = variables.length;
|
||||
|
||||
// Collect unique root hashes from all variables
|
||||
@@ -43,7 +43,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);
|
||||
}
|
||||
@@ -54,7 +54,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;
|
||||
@@ -65,9 +65,9 @@ export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -80,7 +80,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);
|
||||
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);
|
||||
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)).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);
|
||||
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 31 built-in schema aliases", async () => {
|
||||
test("returns a map with 30 built-in schema aliases", async () => {
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const builtinSchemas = bootstrap(store);
|
||||
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/schema");
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/string");
|
||||
@@ -289,39 +294,39 @@ describe("bootstrap", () => {
|
||||
|
||||
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 + 22 outputs)
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
|
||||
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
|
||||
});
|
||||
});
|
||||
|
||||
+49
-14
@@ -2,9 +2,25 @@ export { bootstrap } from "./bootstrap.js";
|
||||
export type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
export { cborEncode } from "./cbor.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";
|
||||
export {
|
||||
type RenderOptions,
|
||||
@@ -21,19 +37,38 @@ export {
|
||||
validate,
|
||||
walk,
|
||||
} from "./schema.js";
|
||||
export { createMemoryStore } from "./store.js";
|
||||
export type { CasNode, Hash, Store } from "./types.js";
|
||||
export type { Variable } from "./variable.js";
|
||||
export {
|
||||
CasNodeNotFoundError,
|
||||
createVariableStore,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
VariableStore,
|
||||
} from "./variable-store.js";
|
||||
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 { validateName } from "./validation.js";
|
||||
export {
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
|
||||
const HASH_RE = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||
|
||||
async function putN(
|
||||
store: ReturnType<typeof createMemoryStore>,
|
||||
type: string,
|
||||
n: number,
|
||||
delayMs = 2,
|
||||
): Promise<string[]> {
|
||||
const hashes: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
hashes.push(store.cas.put(type, { i }));
|
||||
if (delayMs > 0 && i < n - 1) {
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
describe("listByType - pagination + sort + timestamps", () => {
|
||||
test("A1. returns objects with hash/created/updated", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
|
||||
const list = store.cas.listByType(m);
|
||||
for (const e of list) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
expect(typeof e.updated).toBe("number");
|
||||
expect(e.created).toBe(e.updated);
|
||||
}
|
||||
});
|
||||
|
||||
test("A2. default sort is created ASC", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("A3. desc:true reverses order", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("A4. sort: 'updated' is equivalent to 'created' for CAS nodes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
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.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5, 0);
|
||||
expect(store.cas.listByType(m, { limit: 2 })).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("A6. offset skips", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5);
|
||||
|
||||
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.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
expect(store.cas.listByType(m, { limit: 0 })).toEqual([]);
|
||||
});
|
||||
|
||||
test("A8. offset past end returns empty array", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
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.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.cas.listByType(m)).toHaveLength(151);
|
||||
});
|
||||
|
||||
test("A10. desc + offset + limit combined", async () => {
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5, 15);
|
||||
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;
|
||||
expect(got[0]?.hash).toBe(all[n - 2]?.hash as string);
|
||||
expect(got[1]?.hash).toBe(all[n - 3]?.hash as string);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMeta / listSchemas - pagination", () => {
|
||||
test("B1. listMeta returns {hash,created,updated}", async () => {
|
||||
const store = createMemoryStore();
|
||||
const h = await store.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);
|
||||
expect(typeof e.created).toBe("number");
|
||||
expect(typeof e.updated).toBe("number");
|
||||
});
|
||||
|
||||
test("B2. listMeta has no default limit (returns all)", async () => {
|
||||
const store = createMemoryStore();
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await store.cas[BOOTSTRAP_STORE]({ type: "object", i });
|
||||
}
|
||||
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.cas[BOOTSTRAP_STORE]({ type: "object", i });
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
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.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
store.cas.put(m, { type: "string" });
|
||||
store.cas.put(m, { type: "number" });
|
||||
|
||||
const list = store.cas.listSchemas();
|
||||
for (const e of list) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
}
|
||||
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.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
// No delay → likely same millisecond
|
||||
await putN(store, m, 5, 0);
|
||||
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.cas.listByType("0000000000000")).toEqual([]);
|
||||
expect(store.cas.listMeta()).toEqual([]);
|
||||
expect(store.cas.listSchemas()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Hash, ListEntry, ListOptions } from "./types.js";
|
||||
|
||||
/**
|
||||
* Apply sort/desc/offset/limit to an array of `ListEntry` records.
|
||||
* Default sort is by `created` ascending. If `limit` is omitted, all entries
|
||||
* (after offset) are returned.
|
||||
*
|
||||
* Tiebreaker is the entry hash (lexicographic ascending) so ordering remains
|
||||
* deterministic for entries sharing the same timestamp.
|
||||
*/
|
||||
export function applyListOptions(
|
||||
entries: ListEntry[],
|
||||
options?: ListOptions,
|
||||
): ListEntry[] {
|
||||
const sort = options?.sort ?? "created";
|
||||
const desc = options?.desc ?? false;
|
||||
const limit = options?.limit;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
const av = sort === "updated" ? a.updated : a.created;
|
||||
const bv = sort === "updated" ? b.updated : b.created;
|
||||
if (av !== bv) return desc ? bv - av : av - bv;
|
||||
// Hash tiebreaker — stable across calls
|
||||
if (a.hash === b.hash) return 0;
|
||||
return desc ? (a.hash < b.hash ? 1 : -1) : a.hash < b.hash ? -1 : 1;
|
||||
});
|
||||
|
||||
if (limit !== undefined) {
|
||||
if (limit <= 0) return [];
|
||||
return sorted.slice(offset, offset + limit);
|
||||
}
|
||||
return sorted.slice(offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `ListEntry` for a CAS node from its hash and timestamp.
|
||||
* For immutable CAS nodes `created === updated === timestamp`.
|
||||
*/
|
||||
export function casListEntry(hash: Hash, timestamp: number): ListEntry {
|
||||
return { hash, created: timestamp, updated: timestamp };
|
||||
}
|
||||
@@ -1,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 } 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): Hash[] {
|
||||
return this.#inner.listByType(typeHash);
|
||||
}
|
||||
|
||||
listAll(): Hash[] {
|
||||
return this.#inner.listAll();
|
||||
}
|
||||
|
||||
listMeta(): Hash[] {
|
||||
return this.#inner.listMeta();
|
||||
}
|
||||
|
||||
listSchemas(): Hash[] {
|
||||
return this.#inner.listSchemas();
|
||||
}
|
||||
|
||||
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 { describe, expect, test } from "vitest";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
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,10 +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/var-rollback",
|
||||
"@ocas/output/tag",
|
||||
"@ocas/output/untag",
|
||||
"@ocas/output/template-set",
|
||||
"@ocas/output/template-get",
|
||||
"@ocas/output/template-list",
|
||||
@@ -33,22 +27,11 @@ 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(20);
|
||||
|
||||
@@ -58,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");
|
||||
@@ -84,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,21 +27,21 @@ 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/var-history",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\n{% for v in payload.values %}{{ forloop.index0 }}: {{ v }}\n{% endfor %}",
|
||||
"@ocas/output/tag",
|
||||
"{% for t in payload %}{{ t.key }}{% if t.value %}:{{ t.value }}{% endif %}\n{% endfor %}",
|
||||
],
|
||||
[
|
||||
"@ocas/output/var-rollback",
|
||||
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
|
||||
"@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 %}",
|
||||
],
|
||||
[
|
||||
"@ocas/output/template-set",
|
||||
@@ -62,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> = {};
|
||||
@@ -85,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}`;
|
||||
|
||||
+158
-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,29 +720,25 @@ 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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -440,7 +437,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()).toContain(hash);
|
||||
expect(store.listSchemas()).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()).not.toContain(schemaHash);
|
||||
expect(store.listMeta()).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();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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()).toContain(m);
|
||||
expect(store.listSchemas()).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()).not.toContain(m);
|
||||
// schemas typed by deleted meta no longer surface
|
||||
expect(store.listSchemas()).not.toContain(s);
|
||||
expect(store.listSchemas()).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);
|
||||
});
|
||||
});
|
||||
|
||||
+407
-26
@@ -2,10 +2,53 @@ import {
|
||||
BOOTSTRAP_STORE,
|
||||
type BootstrapCapableStore,
|
||||
} from "./bootstrap-capable.js";
|
||||
import { computeHash, computeSelfHash } from "./hash.js";
|
||||
import type { CasNode, Hash } from "./types.js";
|
||||
import { SchemaMismatchError, VariableNotFoundError } from "./errors.js";
|
||||
import { computeHashSync, computeSelfHashSync, initHasher } from "./hash.js";
|
||||
import { applyListOptions, casListEntry } from "./list-utils.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>();
|
||||
@@ -19,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);
|
||||
@@ -29,15 +72,22 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
return hash;
|
||||
}
|
||||
|
||||
const store: BootstrapCapableStore = {
|
||||
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
function entriesForHashes(hashes: Iterable<Hash>): ListEntry[] {
|
||||
const result: ListEntry[] = [];
|
||||
for (const h of hashes) {
|
||||
const node = data.get(h);
|
||||
if (node) result.push(casListEntry(h, node.timestamp));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const store: 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;
|
||||
},
|
||||
|
||||
@@ -49,20 +99,21 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
return data.has(hash);
|
||||
},
|
||||
|
||||
listByType(typeHash: Hash): Hash[] {
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
|
||||
const set = byType.get(typeHash);
|
||||
return set ? [...set] : [];
|
||||
if (!set) return [];
|
||||
return applyListOptions(entriesForHashes(set), options);
|
||||
},
|
||||
|
||||
listAll(): Hash[] {
|
||||
return Array.from(data.keys());
|
||||
},
|
||||
|
||||
listMeta(): Hash[] {
|
||||
return Array.from(metaSet);
|
||||
listMeta(options?: ListOptions): ListEntry[] {
|
||||
return applyListOptions(entriesForHashes(metaSet), options);
|
||||
},
|
||||
|
||||
listSchemas(): Hash[] {
|
||||
listSchemas(options?: ListOptions): ListEntry[] {
|
||||
const result = new Set<Hash>();
|
||||
for (const meta of metaSet) {
|
||||
result.add(meta);
|
||||
@@ -71,23 +122,22 @@ export function createMemoryStore(): BootstrapCapableStore {
|
||||
for (const h of set) result.add(h);
|
||||
}
|
||||
}
|
||||
return Array.from(result);
|
||||
return applyListOptions(entriesForHashes(result), options);
|
||||
},
|
||||
|
||||
delete(hash: Hash): void {
|
||||
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,
|
||||
@@ -95,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 { describe, expect, test } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
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);
|
||||
});
|
||||
});
|
||||
+120
-10
@@ -1,3 +1,5 @@
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
/**
|
||||
* 13-character uppercase Crockford Base32 string produced by XXH64.
|
||||
*/
|
||||
@@ -16,16 +18,124 @@ export type CasNode<T = unknown> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Content-addressable store interface.
|
||||
* Self-referencing nodes are created only via bootstrap().
|
||||
* Sort key for list operations.
|
||||
* - "created": ordering by node creation timestamp (default).
|
||||
* - "updated": ordering by mutation timestamp; for immutable CAS nodes this
|
||||
* is identical to "created".
|
||||
*/
|
||||
export type ListSort = "created" | "updated";
|
||||
|
||||
/**
|
||||
* Common options shared by list operations on the CAS store.
|
||||
*/
|
||||
export type ListOptions = {
|
||||
sort?: ListSort;
|
||||
desc?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One entry in the result of a list operation: a hash plus its
|
||||
* creation/mutation timestamps. For immutable CAS nodes
|
||||
* `created === updated === node.timestamp`.
|
||||
*/
|
||||
export type ListEntry = {
|
||||
hash: Hash;
|
||||
created: number;
|
||||
updated: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronous content-addressable store interface.
|
||||
*/
|
||||
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[];
|
||||
listMeta(options?: ListOptions): ListEntry[];
|
||||
listSchemas(options?: ListOptions): ListEntry[];
|
||||
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 = {
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash>;
|
||||
get(hash: Hash): CasNode | null;
|
||||
has(hash: Hash): boolean;
|
||||
listByType(typeHash: Hash): Hash[];
|
||||
listAll(): Hash[];
|
||||
listMeta(): Hash[];
|
||||
listSchemas(): Hash[];
|
||||
delete(hash: Hash): void;
|
||||
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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,923 +0,0 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { Hash, 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
|
||||
* @ is allowed at the start of the first segment (system-reserved)
|
||||
*/
|
||||
private validateName(name: string): void {
|
||||
// Rule 1: Cannot be empty
|
||||
if (name === "") {
|
||||
throw new InvalidVariableNameError(name, "Name cannot be empty");
|
||||
}
|
||||
|
||||
// Rule 2: No leading slash
|
||||
if (name.startsWith("/")) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot start with leading slash",
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 3: No trailing slash
|
||||
if (name.endsWith("/")) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot end with trailing slash",
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment)
|
||||
const segments = name.split("/");
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i] as string;
|
||||
if (segment === "") {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name contains empty segment (consecutive slashes //)",
|
||||
);
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
// First segment can start with @, all segments can contain [a-zA-Z0-9._-]
|
||||
const regex = i === 0 ? /^@?[a-zA-Z0-9._-]+$/ : /^[a-zA-Z0-9._-]+$/;
|
||||
if (!regex.test(segment)) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
`Segment "${segment}" contains invalid characters (only ${i === 0 ? "@, " : ""}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[];
|
||||
}): 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 ?? [];
|
||||
|
||||
// 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 ")}`;
|
||||
}
|
||||
|
||||
query += " ORDER BY v.created ASC";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back a variable to the value currently at the given history position.
|
||||
* The value at `position` is moved to position 0 (current); positions in
|
||||
* [0, position) are shifted +1.
|
||||
*
|
||||
* Throws VariableNotFoundError if the variable does not exist or there is
|
||||
* no history entry at the given position.
|
||||
*/
|
||||
rollback(name: string, schema: Hash, position: number): Variable {
|
||||
if (!Number.isInteger(position) || position < 0) {
|
||||
throw new Error(
|
||||
`Invalid history position: ${position} (must be a non-negative integer)`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
if (position === 0) {
|
||||
// No-op rollback to current
|
||||
return existing;
|
||||
}
|
||||
|
||||
const targetRow = this.db
|
||||
.prepare(
|
||||
`SELECT value FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = ?`,
|
||||
)
|
||||
.get(name, schema, position) as { value: string } | undefined | null;
|
||||
|
||||
if (!targetRow) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const targetValue = targetRow.value as Hash;
|
||||
const now = Date.now();
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
try {
|
||||
const changed = this.recordHistory(name, schema, targetValue, now);
|
||||
if (changed) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE variables SET value = ?, updated = ? WHERE name = ? AND schema = ?`,
|
||||
)
|
||||
.run(targetValue, now, name, schema);
|
||||
}
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
const updated = this.get(name, schema);
|
||||
if (updated === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
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,179 @@ 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 +439,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 +454,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 +465,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 +480,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 +498,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 +520,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 +531,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 +543,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 +561,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 +584,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 +597,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 +610,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 +628,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 +671,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 +681,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 +694,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 +711,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 +733,7 @@ describe("Test Suite 8: Performance and Edge Cases", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const hash = await putSchema(store, schema);
|
||||
const hash = putSchema(store, schema);
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
+31
-59
@@ -1,70 +1,42 @@
|
||||
# @uncaged/json-cas-fs
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
|
||||
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
|
||||
- `openStore()` is now async and auto-bootstraps
|
||||
|
||||
### New Features
|
||||
|
||||
- 18 `@output/*` schemas registered at bootstrap
|
||||
- `list --type <hash-or-alias>` command (replaces `schema list`)
|
||||
- `verify` now checks both hash integrity and schema validation
|
||||
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
|
||||
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
|
||||
- Default LiquidJS templates for all output schemas
|
||||
- Pipe composition: any command output can be piped to `render -p`
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.6.0
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feat: add oneOf support to meta-schema validation
|
||||
|
||||
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.
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.5.3
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.3.0
|
||||
# @ocas/fs
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
### Breaking Changes
|
||||
|
||||
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
|
||||
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties
|
||||
- `bootstrap(store)` and `putSchema(store, schema)` are now **synchronous** (were async)
|
||||
- `VariableStore` class removed from `@ocas/core` — SQLite implementation moved to `@ocas/fs`
|
||||
- `createVariableStore()` removed from `@ocas/core`
|
||||
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`
|
||||
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally
|
||||
- `@ocas/core` has zero `bun:sqlite` imports — pure TypeScript
|
||||
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead
|
||||
|
||||
### New Features
|
||||
|
||||
- `CasStore`, `VarStore`, `TagStore` sub-store types
|
||||
- `TagStore` — first-class tags on any CAS node (not just variables)
|
||||
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands
|
||||
- `ocas get` and `ocas var get` now include tag info in output
|
||||
- `ocas list --tag` and `ocas var list --tag` filter support
|
||||
- `var-store-helpers.ts` — shared validation/history logic
|
||||
- `validation.ts` — shared `validateName()` exported from core
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.2.0
|
||||
- Updated dependencies:
|
||||
- @ocas/core@0.1.2
|
||||
|
||||
## 0.1.3
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @uncaged/json-cas@0.1.3
|
||||
- Updated dependencies:
|
||||
- @ocas/core@0.1.1
|
||||
|
||||
## 0.1.0
|
||||
|
||||
Initial release as `@ocas/fs`. Filesystem-backed CAS store with SQLite indexing and auto-bootstrap.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ocas/fs",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -14,12 +14,22 @@
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"cborg": "^4.2.3",
|
||||
"@ocas/core": "workspace:*"
|
||||
"@ocas/core": "0.2.1",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"cborg": "^4.2.3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/ocas.git",
|
||||
"directory": "packages/fs"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/fs",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/ocas/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { createFsStore, openStore, prepareStore } from "./store.js";
|
||||
export { createSqliteVarStore } from "./sqlite-store.js";
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import type {
|
||||
CasStore,
|
||||
Hash,
|
||||
HistoryEntry,
|
||||
ListEntry,
|
||||
ListOptions,
|
||||
Tag,
|
||||
TagOp,
|
||||
TagStore,
|
||||
Variable,
|
||||
VarListOptions,
|
||||
VarSetOptions,
|
||||
VarStore,
|
||||
} from "@ocas/core";
|
||||
import {
|
||||
addNameIndex,
|
||||
checkTagLabelConflict,
|
||||
extractSchema,
|
||||
MAX_HISTORY,
|
||||
pushHistory,
|
||||
removeNameIndex,
|
||||
SchemaMismatchError,
|
||||
VariableNotFoundError,
|
||||
type VarRecord,
|
||||
validateName,
|
||||
varKey,
|
||||
cloneVarRecord,
|
||||
} from "@ocas/core";
|
||||
|
||||
const DB_FILE = "_store.db";
|
||||
const VARS_FILE = "_vars.jsonl";
|
||||
const TAGS_FILE = "_tags.jsonl";
|
||||
|
||||
function openDb(dir: string): Database.Database {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const db = new Database(join(dir, DB_FILE));
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
return db;
|
||||
}
|
||||
|
||||
function initVarTables(db: Database.Database): 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: Database.Database): 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: Database.Database,
|
||||
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 (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const migrate = db.transaction(() => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
migrate();
|
||||
}
|
||||
|
||||
function migrateJsonlTags(db: Database.Database, 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 (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const migrate = db.transaction(() => {
|
||||
for (const tm of byTarget.values()) {
|
||||
for (const tag of tm.values()) {
|
||||
insertTag.run(tag.target, tag.key, tag.value, tag.created);
|
||||
}
|
||||
}
|
||||
});
|
||||
migrate();
|
||||
}
|
||||
|
||||
// ── 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 ──
|
||||
|
||||
const txnSetVar = db.transaction(
|
||||
(
|
||||
name: string,
|
||||
schema: Hash,
|
||||
hash: Hash,
|
||||
now: number,
|
||||
tagsJson: string,
|
||||
labelsJson: string,
|
||||
isNew: boolean,
|
||||
valueChanged: boolean,
|
||||
) => {
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const txnTagOps = db.transaction(
|
||||
(target: Hash, operations: TagOp[], now: number) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const txnUntag = db.transaction((target: Hash, keys: string[]) => {
|
||||
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: unknown[] = [];
|
||||
|
||||
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: unknown[] = [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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
@@ -50,24 +50,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(30);
|
||||
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,8 +112,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);
|
||||
@@ -169,7 +169,7 @@ describe("createFsStore – has and list", () => {
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
const h3 = await store.put(typeHash, { a: 3 });
|
||||
|
||||
const all = store.listByType(typeHash);
|
||||
const all = store.listByType(typeHash).map((e) => e.hash);
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all).toContain(h1);
|
||||
expect(all).toContain(h2);
|
||||
@@ -213,7 +213,7 @@ describe("createFsStore – listByType", () => {
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
await store.put(otherType, { b: 1 });
|
||||
|
||||
const byType = store.listByType(typeHash);
|
||||
const byType = store.listByType(typeHash).map((e) => e.hash);
|
||||
expect(byType).toHaveLength(2);
|
||||
expect(byType).toContain(h1);
|
||||
expect(byType).toContain(h2);
|
||||
@@ -227,7 +227,7 @@ describe("createFsStore – listByType", () => {
|
||||
const h2 = await store1.put(typeHash, { x: 2 });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const byType = store2.listByType(typeHash);
|
||||
const byType = store2.listByType(typeHash).map((e) => e.hash);
|
||||
expect(byType).toHaveLength(2);
|
||||
expect(byType).toContain(h1);
|
||||
expect(byType).toContain(h2);
|
||||
@@ -241,7 +241,7 @@ describe("createFsStore – listByType", () => {
|
||||
await store1.put(typeHash, { n: 7 });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listByType(typeHash)).toEqual([hash]);
|
||||
expect(store2.listByType(typeHash).map((e) => e.hash)).toEqual([hash]);
|
||||
});
|
||||
|
||||
test("rebuilds _index from .bin files when index is missing", async () => {
|
||||
@@ -254,18 +254,18 @@ describe("createFsStore – listByType", () => {
|
||||
rmSync(join(dir, "_index"), { recursive: true, force: true });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listByType(typeHash)).toEqual([h1, h2]);
|
||||
expect(store2.listByType(typeHash).map((e) => e.hash)).toEqual([h1, h2]);
|
||||
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
|
||||
expect(readdirSync(join(dir, "_index"))).toContain(typeHash);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(store2.listByType(hash)).toContain(hash);
|
||||
expect(store2.listByType(hash).map((e) => e.hash)).toContain(hash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,8 +294,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 +336,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 +349,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 +375,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 +405,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -474,7 +474,7 @@ describe("createFsStore – listMeta and listSchemas", () => {
|
||||
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "b" });
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
const meta = store2.listMeta();
|
||||
const meta = store2.listMeta().map((e) => e.hash);
|
||||
expect(meta).toContain(h1);
|
||||
expect(meta).toContain(h2);
|
||||
expect(meta).toHaveLength(2);
|
||||
@@ -492,13 +492,13 @@ describe("createFsStore – listMeta and listSchemas", () => {
|
||||
expect(existsSync(metaPath)).toBe(false);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listMeta()).toContain(h1);
|
||||
expect(store2.listMeta().map((e) => e.hash)).toContain(h1);
|
||||
expect(existsSync(metaPath)).toBe(true);
|
||||
const content = readFileSync(metaPath, "utf8");
|
||||
expect(content).toContain(h1);
|
||||
|
||||
// unrelated type hash not in meta
|
||||
expect(store2.listMeta()).not.toContain(t);
|
||||
expect(store2.listMeta().map((e) => e.hash)).not.toContain(t);
|
||||
});
|
||||
|
||||
test("C5. existing _meta is not overwritten", async () => {
|
||||
@@ -522,7 +522,7 @@ describe("createFsStore – listMeta and listSchemas", () => {
|
||||
const s2 = await store.put(m, { type: "number" });
|
||||
const s3 = await store.put(m, { type: "array" });
|
||||
|
||||
const schemas = store.listSchemas();
|
||||
const schemas = store.listSchemas().map((e) => e.hash);
|
||||
expect(schemas).toHaveLength(4);
|
||||
expect(schemas).toContain(m);
|
||||
expect(schemas).toContain(s1);
|
||||
@@ -543,8 +543,8 @@ describe("createFsStore – listMeta and listSchemas", () => {
|
||||
expect(content).toContain(h2);
|
||||
|
||||
const store2 = createFsStore(dir);
|
||||
expect(store2.listMeta()).not.toContain(h1);
|
||||
expect(store2.listMeta()).toContain(h2);
|
||||
expect(store2.listMeta().map((e) => e.hash)).not.toContain(h1);
|
||||
expect(store2.listMeta().map((e) => e.hash)).toContain(h2);
|
||||
});
|
||||
|
||||
test("C8. fresh store with no self-ref puts has empty listMeta", () => {
|
||||
@@ -558,3 +558,33 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
+102
-78
@@ -10,25 +10,32 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
BootstrapCapableStore,
|
||||
CasNode,
|
||||
Hash,
|
||||
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";
|
||||
|
||||
// Initialise the xxhash WASM instance once at module load so the FS CAS
|
||||
// store can use the synchronous hashing functions.
|
||||
await initHasher();
|
||||
|
||||
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -174,15 +181,36 @@ function appendToTypeIndex(
|
||||
typeIndex.set(type, list);
|
||||
}
|
||||
|
||||
export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
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): FsCasStore {
|
||||
const data = new Map<Hash, CasNode>();
|
||||
loadDir(dir, data);
|
||||
const indexDir = join(dir, INDEX_DIR);
|
||||
const typeIndex = loadOrMigrateTypeIndex(dir, data);
|
||||
const metaSet = loadOrMigrateMetaSet(dir, data);
|
||||
|
||||
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)) {
|
||||
const node: CasNode = { type: hash, payload, timestamp: Date.now() };
|
||||
data.set(hash, node);
|
||||
@@ -202,9 +230,9 @@ 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)) {
|
||||
const node: CasNode = {
|
||||
@@ -237,19 +265,21 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
return data.has(hash);
|
||||
},
|
||||
|
||||
listByType(typeHash: Hash): Hash[] {
|
||||
return typeIndex.get(typeHash) ?? [];
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
|
||||
const list = typeIndex.get(typeHash);
|
||||
if (!list) return [];
|
||||
return applyListOptions(hashesToEntries(data, list), options);
|
||||
},
|
||||
|
||||
listAll(): Hash[] {
|
||||
return Array.from(data.keys());
|
||||
},
|
||||
|
||||
listMeta(): Hash[] {
|
||||
return Array.from(metaSet);
|
||||
listMeta(options?: ListOptions): ListEntry[] {
|
||||
return applyListOptions(hashesToEntries(data, metaSet), options);
|
||||
},
|
||||
|
||||
listSchemas(): Hash[] {
|
||||
listSchemas(options?: ListOptions): ListEntry[] {
|
||||
const result = new Set<Hash>();
|
||||
for (const meta of metaSet) {
|
||||
result.add(meta);
|
||||
@@ -258,46 +288,46 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
for (const h of list) result.add(h);
|
||||
}
|
||||
}
|
||||
return Array.from(result);
|
||||
return applyListOptions(hashesToEntries(data, result), options);
|
||||
},
|
||||
|
||||
delete(hash: Hash): void {
|
||||
delete(hash: Hash): boolean {
|
||||
const node = data.get(hash);
|
||||
if (node) {
|
||||
data.delete(hash);
|
||||
// Delete file
|
||||
try {
|
||||
unlinkSync(join(dir, `${hash}.bin`));
|
||||
} catch {
|
||||
// ignore if file doesn't exist
|
||||
if (!node) return false;
|
||||
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);
|
||||
if (idx !== -1) {
|
||||
list.splice(idx, 1);
|
||||
}
|
||||
// Remove from type index
|
||||
const list = typeIndex.get(node.type);
|
||||
if (list) {
|
||||
const idx = list.indexOf(hash);
|
||||
if (idx !== -1) {
|
||||
list.splice(idx, 1);
|
||||
if (list.length === 0) {
|
||||
typeIndex.delete(node.type);
|
||||
// Delete empty index file
|
||||
try {
|
||||
unlinkSync(join(indexDir, node.type));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (list.length === 0) {
|
||||
typeIndex.delete(node.type);
|
||||
// Delete empty index file
|
||||
try {
|
||||
unlinkSync(join(indexDir, node.type));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
// Rewrite index file
|
||||
const body = `${list.join("\n")}\n`;
|
||||
writeFileSync(join(indexDir, node.type), body, "utf8");
|
||||
}
|
||||
}
|
||||
// Remove from meta set if applicable
|
||||
if (metaSet.has(hash)) {
|
||||
metaSet.delete(hash);
|
||||
rewriteMetaSet(indexDir, metaSet);
|
||||
} else {
|
||||
// Rewrite index file
|
||||
const body = `${list.join("\n")}\n`;
|
||||
writeFileSync(join(indexDir, node.type), body, "utf8");
|
||||
}
|
||||
}
|
||||
// Remove from meta set if applicable
|
||||
if (metaSet.has(hash)) {
|
||||
metaSet.delete(hash);
|
||||
rewriteMetaSet(indexDir, metaSet);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
[BOOTSTRAP_STORE]: putSelfReferencing,
|
||||
@@ -307,19 +337,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 });
|
||||
@@ -356,25 +383,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 { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
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 { 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
+3007
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
name: "@ocas/workspace"
|
||||
runtime: node
|
||||
packages:
|
||||
- name: "@ocas/core"
|
||||
path: packages/core
|
||||
type: lib
|
||||
- name: "@ocas/fs"
|
||||
path: packages/fs
|
||||
type: lib
|
||||
- name: "@ocas/cli"
|
||||
path: packages/cli
|
||||
type: cli
|
||||
changeset:
|
||||
fixed: true
|
||||
release:
|
||||
registry: https://registry.npmjs.org
|
||||
access: public
|
||||
gitTagPrefix: v
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Prevent publishing packages that still reference workspace:* dependencies
|
||||
if grep -q '"workspace:' package.json 2>/dev/null; then
|
||||
echo "❌ Found workspace:* dependencies in package.json — cannot publish directly."
|
||||
echo " Use 'changeset publish' which resolves workspace protocol automatically."
|
||||
grep '"workspace:' package.json
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# OCAS Release Preparation
|
||||
# Creates a release branch, runs changeset version, and validates.
|
||||
#
|
||||
# Usage: ./scripts/prepare-release.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Clean working tree on main branch
|
||||
# - Pending changesets in .changeset/
|
||||
|
||||
BOLD='\033[1m'
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BOLD}${GREEN}✓${NC} $1"; }
|
||||
warn() { echo -e "${BOLD}${YELLOW}⚠${NC} $1"; }
|
||||
error() { echo -e "${BOLD}${RED}✗${NC} $1"; exit 1; }
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
|
||||
echo -e "\n${BOLD}OCAS Release Preparation${NC}\n"
|
||||
|
||||
# Must be on main
|
||||
BRANCH=$(git branch --show-current)
|
||||
[[ "$BRANCH" == "main" ]] || error "Must be on main branch (currently on $BRANCH)"
|
||||
|
||||
# Clean working tree
|
||||
[[ -z "$(git status --porcelain)" ]] || error "Working tree is not clean. Commit or stash changes first."
|
||||
|
||||
# Check for pending changesets
|
||||
CHANGESETS=$(ls .changeset/*.md 2>/dev/null | grep -v README.md || true)
|
||||
if [[ -z "$CHANGESETS" ]]; then
|
||||
error "No pending changesets found. Run 'bunx changeset' to add one first."
|
||||
fi
|
||||
|
||||
info "Found pending changesets:"
|
||||
for cs in $CHANGESETS; do
|
||||
echo " $(basename "$cs")"
|
||||
done
|
||||
|
||||
# --- Determine version ---
|
||||
|
||||
# Dry-run to peek at the version bump
|
||||
echo ""
|
||||
info "Previewing version changes..."
|
||||
bunx changeset status
|
||||
|
||||
# --- Create release branch ---
|
||||
|
||||
echo ""
|
||||
read -rp "Proceed with release branch? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
# Get current version to name the branch
|
||||
CURRENT_VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
|
||||
info "Current version: $CURRENT_VERSION"
|
||||
|
||||
git fetch origin
|
||||
git checkout -b release/next
|
||||
|
||||
# --- Run changeset version ---
|
||||
|
||||
info "Running changeset version..."
|
||||
bunx changeset version
|
||||
|
||||
# Show what changed
|
||||
NEW_VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
|
||||
info "New version: $NEW_VERSION"
|
||||
|
||||
# Rename branch to include actual version
|
||||
git branch -m "release/next" "release/$NEW_VERSION"
|
||||
info "Release branch: release/$NEW_VERSION"
|
||||
|
||||
# --- Validate ---
|
||||
|
||||
echo ""
|
||||
info "Running validation..."
|
||||
|
||||
echo " → bun install"
|
||||
bun install --no-cache
|
||||
|
||||
echo " → bun run build"
|
||||
bun run build
|
||||
|
||||
echo " → bun run check"
|
||||
bun run check || warn "Lint warnings found (review above)"
|
||||
|
||||
echo " → bun test"
|
||||
bun test || error "Tests failed!"
|
||||
|
||||
info "All checks passed"
|
||||
|
||||
# --- Commit ---
|
||||
|
||||
git add -A
|
||||
git commit -m "chore(release): prepare v$NEW_VERSION"
|
||||
|
||||
echo ""
|
||||
info "Release branch ready: release/$NEW_VERSION"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Review changes: git diff main...HEAD"
|
||||
echo " 2. Fix issues if needed, commit to this branch"
|
||||
echo " 3. When ready: ./scripts/publish.sh"
|
||||
echo ""
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# OCAS Publish
|
||||
# Builds, publishes to npm, tags, and pushes.
|
||||
#
|
||||
# Usage: ./scripts/publish.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - On a release/* branch (created by prepare-release.sh)
|
||||
# - npm authenticated (`npm whoami` works)
|
||||
# - All checks passing
|
||||
|
||||
BOLD='\033[1m'
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BOLD}${GREEN}✓${NC} $1"; }
|
||||
warn() { echo -e "${BOLD}${YELLOW}⚠${NC} $1"; }
|
||||
error() { echo -e "${BOLD}${RED}✗${NC} $1"; exit 1; }
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
|
||||
echo -e "\n${BOLD}OCAS Publish${NC}\n"
|
||||
|
||||
# Must be on release/* branch
|
||||
BRANCH=$(git branch --show-current)
|
||||
[[ "$BRANCH" == release/* ]] || error "Must be on a release/* branch (currently on $BRANCH)"
|
||||
|
||||
# Clean working tree
|
||||
[[ -z "$(git status --porcelain)" ]] || error "Working tree is not clean. Commit changes first."
|
||||
|
||||
# Extract version
|
||||
VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
|
||||
info "Publishing version: $VERSION"
|
||||
|
||||
# No pending changesets (should have been consumed by prepare-release.sh)
|
||||
CHANGESETS=$(ls .changeset/*.md 2>/dev/null | grep -v README.md || true)
|
||||
if [[ -n "$CHANGESETS" ]]; then
|
||||
error "Pending changesets found. Run prepare-release.sh first."
|
||||
fi
|
||||
|
||||
# npm auth check
|
||||
npm whoami &>/dev/null || error "Not authenticated with npm. Run 'npm login' first."
|
||||
NPM_USER=$(npm whoami)
|
||||
info "npm user: $NPM_USER"
|
||||
|
||||
# --- Final validation ---
|
||||
|
||||
info "Running final validation..."
|
||||
|
||||
echo " → bun run build"
|
||||
bun run build
|
||||
|
||||
echo " → bun test"
|
||||
bun test || error "Tests failed! Fix before publishing."
|
||||
|
||||
# --- Confirm ---
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Will publish:${NC}"
|
||||
for pkg in core fs cli; do
|
||||
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
|
||||
echo " $PKG_NAME@$VERSION"
|
||||
done
|
||||
echo ""
|
||||
read -rp "Publish to npm? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
# --- Publish (order matters: core → fs → cli) ---
|
||||
|
||||
for pkg in core fs cli; do
|
||||
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
|
||||
echo ""
|
||||
info "Publishing $PKG_NAME@$VERSION..."
|
||||
(cd "packages/$pkg" && bun publish --access public)
|
||||
info "$PKG_NAME@$VERSION published ✓"
|
||||
done
|
||||
|
||||
# --- Tag and push ---
|
||||
|
||||
echo ""
|
||||
TAG="v$VERSION"
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
git push origin "$BRANCH"
|
||||
git push origin "$TAG"
|
||||
info "Tag $TAG pushed"
|
||||
|
||||
# --- Merge back to main ---
|
||||
|
||||
echo ""
|
||||
info "Merging release into main..."
|
||||
git checkout main
|
||||
git merge "$BRANCH" --no-ff -m "chore: merge release $TAG"
|
||||
git push origin main
|
||||
info "Merged to main"
|
||||
|
||||
# Clean up release branch
|
||||
git branch -d "$BRANCH"
|
||||
git push origin --delete "$BRANCH" 2>/dev/null || true
|
||||
info "Release branch cleaned up"
|
||||
|
||||
echo ""
|
||||
info "Release $TAG complete! 🎉"
|
||||
echo ""
|
||||
echo " Published:"
|
||||
for pkg in core fs cli; do
|
||||
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
|
||||
echo " https://www.npmjs.com/package/$PKG_NAME"
|
||||
done
|
||||
echo ""
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["bun-types"],
|
||||
"types": ["node"],
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["packages/*/src/**/*.test.ts", "packages/*/tests/**/*.test.ts"],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user