64 Commits

Author SHA1 Message Date
xiaoju 185c4c779b chore: release v0.2.0 2026-06-02 12:53:38 +00:00
xiaoju b49da8ebbd chore: publish @ocas/*@0.2.0-rc.1 2026-06-02 12:19:29 +00:00
xiaoju 298a8ec626 chore: prepare release/0.2.0 — fix workspace:* to real versions 2026-06-02 11:50:47 +00:00
xiaoju 67620e0d57 docs: add changesets for #52/#53 and update card docs for tag commands 2026-06-02 11:45:37 +00:00
xiaoju fcb514ef50 Merge pull request 'feat: --tag filter for list / var list' (#57) from fix/54-list-tag-filter into main 2026-06-02 11:39:29 +00:00
xiaoju ca334692b7 feat: add --tag filter to ocas list and ocas var list
Multiple --tag flags AND together. Tag format: key:value for tags,
bare name for labels. Without --tag, existing behavior is preserved.

Fixes #54
2026-06-02 11:36:55 +00:00
xiaoju 54f225a514 Merge pull request 'feat: show tag info in get / var get output' (#56) from fix/53-tag-info-output into main 2026-06-02 11:26:51 +00:00
xiaoju 4ebae73257 feat: add tag info to ocas get and ocas var get output
When a CAS node has tags, ocas get includes them in the envelope's
value as a `tags` array. When a variable's value hash has tags,
ocas var get includes them as a `valueTags` array (separate from
the variable's own tags/labels). Untagged nodes/values produce
byte-identical output as before (no empty array serialized).

Schemas @ocas/output/get and @ocas/output/var-get extended to
accept the new optional fields.

Closes #53
2026-06-02 11:24:39 +00:00
xiaoju 78ab8538cd Merge pull request 'feat: top-level ocas tag/untag commands' (#55) from fix/52-tag-untag into main 2026-06-02 11:13:57 +00:00
xiaoju 561f2a33b7 feat: add top-level ocas tag/untag commands, remove var tag
Adds `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...`
top-level CLI commands operating on store.tag.* (TagStore). Targets
may be hashes or @scope/name variables (resolved via resolveHash).

The redundant `ocas var tag` subcommand is removed; `var tag` now
falls through to "Unknown var subcommand: tag".

Registers `@ocas/output/tag` and `@ocas/output/untag` schemas and
templates in bootstrap; removes `@ocas/output/var-tag`.

Closes #52

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-02 11:11:29 +00:00
xiaoju 3fb179abde chore: add changeset for unified Store refactor (#38) 2026-06-02 10:28:52 +00:00
xiaoju f22eb5deab docs: update cards and CLAUDE.md for unified Store refactor 2026-06-02 10:23:49 +00:00
xiaoju 0735e7ca24 Merge pull request 'fix(core,fs): phase 3 cleanup — drop legacy Store, sync bootstrap, dedup VarStore' (#49) from fix/47-phase3-cleanup into main 2026-06-02 10:10:42 +00:00
xiaoju 0041fc4e23 fix(core,fs): phase 3 cleanup — drop legacy Store, sync bootstrap, dedup VarStore
- Remove legacy `Store` type with `Promise<Hash>` `put`; rename `OcasStore` → `Store`
  across @ocas/core, @ocas/fs, @ocas/cli (production + tests).
- Migrate `BootstrapCapableStore` to `CasStore`; `[BOOTSTRAP_STORE]` returns `Hash`
  synchronously.
- Make `bootstrap()` and `putSchema()` synchronous; remove `await` at all call sites.
- Extract pure VarStore helpers into `packages/core/src/var-store-helpers.ts`
  (`varKey`, `addNameIndex`, `removeNameIndex`, `extractSchema`, `checkTagLabelConflict`,
  `pushHistory`, `cloneVarRecord`, `VarRecord`); both `MemoryVarStore` (-74 lines) and
  `FsVarStore` (-63 lines) now delegate to them while keeping persistence separate.

Refs #47
2026-06-02 10:02:19 +00:00
xiaoju e398d090b0 Merge pull request 'fix(fs): add FsStore var/tag test coverage and share validateName' (#48) from fix/42-fsstore-implements-store into main 2026-06-02 09:35:08 +00:00
xiaoju 9965e75c22 feat(fs): add FsStore var/tag test coverage and share validateName
Phase 4 of unified Store refactor (#38):
- Add FsVarStore tests (12 cases) and FsTagStore tests (11 cases)
  covering CRUD, persistence-across-reopen, JSONL replay fidelity,
  ListOptions, and error paths.
- Extract validateName into packages/core/src/validation.ts; remove
  duplicated copies in core/src/store.ts and fs/src/var-store.ts.
- Fix FsTagStore.listByTag to honor ListOptions (limit/offset/desc)
  via applyListOptions, matching the in-memory implementation.
- Replace stale openStoreAndVarStore example in usage.md with
  openStore returning OcasStore; add grep-based regression test.
- Add OcasStore shape assertion in fs/src/store.test.ts.

Closes #42; partially addresses #47 (items 1, 3).
2026-06-02 09:27:44 +00:00
xiaoju 385815534f Merge pull request 'refactor(core): update all functions to accept OcasStore (single param)' (#46) from fix/41-ocas-store-single-param into main 2026-06-02 09:05:57 +00:00
xiaoju e53f473fc2 refactor(core): update all functions to accept OcasStore (single param)
- bootstrap(store) — uses store.cas + store.var
- gc(store) — uses store.cas + store.var
- render/renderAsync/renderDirect — uses store.cas + store.var for templates
- refs/walk/validate/getSchema/putSchema — uses store.cas
- wrapEnvelope — uses store.cas + store.var
- registerOutputTemplates — uses store.cas + store.var
- Remove VariableStore SQLite class from @ocas/core
- Zero bun:sqlite imports in core
- Update @ocas/fs and @ocas/cli to new signatures
- 560 tests pass

Fixes #41
2026-06-02 08:15:07 +00:00
xiaoju 8c075b3310 Merge pull request 'feat(core): MemoryStore returns OcasStore (cas + var + tag)' (#45) from fix/40-memorystore-ocas into main 2026-06-02 06:54:24 +00:00
xiaoju 0f10580937 test: update snapshots for Phase 2 changes 2026-06-02 06:54:09 +00:00
xiaoju 56e3253829 feat(core): MemoryStore returns OcasStore (cas + var + tag)
Rewrite createMemoryStore() to return an OcasStore with three sub-stores:
cas, var, tag. The cas sub-store keeps the existing Map-based logic, but
its put is now synchronous; the in-memory VarStore and TagStore are new
Map-based implementations of the types defined in #39.

The legacy Store.put signature is widened to Hash | Promise<Hash> so that
the new sync cas can still be passed to helpers (bootstrap, gc, render,
schema, …) that have not yet been migrated to the unified surface.

Tests in packages/core were mechanically updated from store.* to
store.cas.* and new test suites for VarStore (var-store.test.ts) and
TagStore (tag-store.test.ts) cover the spec from issue #40.

Closes #40

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-02 06:30:01 +00:00
xiaoju 7ff3e438be Merge pull request 'feat(core): define CasStore, VarStore, TagStore, Store types' (#44) from fix/39-store-types into main 2026-06-02 05:56:07 +00:00
xiaoju 828514def5 feat(core): define CasStore, VarStore, TagStore, Store types
Fixes #39
2026-06-02 05:54:32 +00:00
xiaoju c83e98cd7e docs: use @ocas/ prefix consistently in test names, comments, and error messages 2026-06-02 04:40:06 +00:00
xiaoju db5272a64a docs(cli): fix README — use @ocas/schema instead of @schema shorthand 2026-06-02 04:29:01 +00:00
xiaoju e1c4fb86bc chore: restore workspace:* deps on main 2026-06-02 04:27:01 +00:00
xiaoju 0f1bf0383a chore: merge release v0.1.2 2026-06-02 04:27:01 +00:00
xiaoju 3d19707e7b chore: release v0.1.2 2026-06-02 04:26:49 +00:00
xiaoju 20922d92c2 chore: publish 0.1.2-rc.2 2026-06-02 04:19:23 +00:00
xiaoju b22da63bf8 chore: update changeset description for postinstall → help hint change 2026-06-02 04:06:56 +00:00
xiaoju 45f0d83af3 fix(cli): replace postinstall with help hint, update setup to guide old skill cleanup
- Remove postinstall script (blocked by bun security policy)
- Add agent skill hint with version to help output
- Update setup.md to guide cleanup of old skill versions
2026-06-02 04:06:43 +00:00
xiaoju 56f003077a chore: publish 0.1.2-rc.1 2026-06-02 03:37:43 +00:00
xiaoju 52ebd9ed8e chore: prepare release/0.1.2 — fix workspace deps to 0.1.1 2026-06-02 03:32:49 +00:00
xiaoju ec6db5f494 fix(cli): use bun for postinstall (consistent with cli runtime) 2026-06-02 03:24:19 +00:00
xiaoju 5d54c0988c fix(cli): use node instead of sh for postinstall (Windows compat) 2026-06-02 03:22:20 +00:00
xiaoju fbcd1b5b0e chore: add changeset for postinstall message 2026-06-02 03:21:11 +00:00
xiaoju 191926428a feat(cli): add postinstall message guiding agents to run ocas prompt setup 2026-06-02 03:18:38 +00:00
xiaoju 077537ea5b chore: add changesets for #35 render newline fix and #37 LSP errors fix 2026-06-02 03:14:20 +00:00
xiaoju 84386f8a63 docs: add 0.1.0 initial release entries to CHANGELOGs 2026-06-02 03:10:43 +00:00
xiaoju eebdeb23da chore: clean up CHANGELOGs — remove pre-OCAS history
Drop all entries from the uncaged/json-cas era (≤0.6.0). OCAS starts
at 0.1.0; only 0.1.1+ entries are relevant.
2026-06-02 03:09:15 +00:00
xiaoju 350ee3d7b5 style: fix all biome warnings and infos
- Use template literals instead of string concatenation
- Remove unused imports and variables
- Use export type for type-only exports
- Use literal keys instead of computed string keys
2026-06-02 03:07:34 +00:00
xiaoju 02a364eb0b Merge pull request 'fix: resolve all TypeScript LSP errors in CLI package' (#37) from fix/36-cli-lsp-errors into main 2026-06-02 02:59:18 +00:00
xiaoju 8176a228b2 fix: resolve all TypeScript LSP errors in CLI package
1. CLI tsconfig: disable composite/declaration/declarationMap, enable
   noEmit — CLI runs via bun, never compiled by tsc. Eliminates all
   TS6059/TS6307 rootDir errors.

2. Use conditional spread to filter undefined values before passing to
   renderAsync/renderDirect/tag — satisfies exactOptionalPropertyTypes.

3. Fix TS2304: schemaHash not in scope in template delete catch block,
   use schemaInput instead.

Fixes #36
2026-06-02 02:58:47 +00:00
xiaomo 2952b953b3 Merge pull request 'fix: render trailing newline and pipe mode template rendering' (#35) from fix/33-34-render-bugs into main 2026-06-02 02:39:43 +00:00
xiaoju 1554fbc719 fix: render trailing newline and pipe mode template rendering
- Add trailing newline to all render output (#33)
- Fix render -p pipe mode to resolve hash values through renderAsync
  with template support instead of renderDirect (#34)
- Always open varStore in render command (needed for pipe+hash path)
- Update test expectations for trailing newline

Fixes #33
Fixes #34
2026-06-02 02:34:36 +00:00
xiaoju 3d903eaff8 chore: restore workspace:* deps on main 2026-06-02 02:08:29 +00:00
xiaoju d8f2abbe12 chore: merge release v0.1.1 2026-06-02 02:08:15 +00:00
xiaoju 0919f71f7e chore(release): v0.1.1
- ocas prompt usage/setup commands
- --version flag
- CHANGELOG cleanup: fix package names @uncaged/* → @ocas/*
2026-06-02 02:07:48 +00:00
xiaoju 944f6c3254 chore: bump version to 0.1.1-rc.2 2026-06-02 02:00:58 +00:00
xiaoju 6bc767d37e docs: update release process — three-phase prepare/candidate/finalize
Changeset files are only consumed once at finalize. Prerelease (rc)
never touches changesets or CHANGELOG.
2026-06-02 01:52:32 +00:00
xiaoju efb0a0e721 chore: add changeset for --version flag 2026-06-02 00:46:23 +00:00
xiaoju 5f544c019f feat: add --version flag to CLI
Reads version from package.json and prints to stdout.

Fixes #32
2026-06-02 00:39:22 +00:00
xiaoju ab1e6df774 feat: add --version flag to CLI
Reads version from package.json and prints to stdout.

Fixes #32
2026-06-02 00:39:16 +00:00
xiaoju b43bbef80f chore: update release scripts and CLAUDE.md
- prepare-release.sh: delete consumed changesets from main after branching
- publish.sh: add --rc flag for prerelease (rc.N, auto-increment)
- publish.sh: auto-fix workspace:* and version in all packages
- publish.sh: auto-restore workspace:* on main after final release
- CLAUDE.md: document changeset-on-main, prerelease, and final release flows
2026-06-02 00:14:07 +00:00
xiaoju b687ff82b3 style: biome format fixes 2026-06-02 00:12:43 +00:00
xiaoju 04433e81ab chore: set version 0.1.1-rc.1 for prerelease 2026-06-02 00:01:57 +00:00
xiaoju c9829e70ee chore: fix workspace deps for publish 2026-06-02 00:01:50 +00:00
xiaoju b19f38c41a style: biome format fixes 2026-06-02 00:01:37 +00:00
xiaoju 52072a0065 chore(release): prepare v0.1.1 2026-06-02 00:01:25 +00:00
xiaoju ec9e04c5c6 chore: add changeset for prompt commands 2026-06-01 23:56:16 +00:00
xiaomo 558a70fc04 Merge pull request 'feat: add ocas prompt usage/setup commands' (#31) from feat/prompt-command into main 2026-06-01 23:54:12 +00:00
xiaoju 656c780270 feat: add ocas prompt usage and ocas prompt setup commands
- `ocas prompt usage` — outputs skill body (SKILL.md content without frontmatter)
- `ocas prompt setup` — outputs agent installation instructions

Prompt content is bundled with the CLI and versioned with it.
2026-06-01 23:53:12 +00:00
xiaoju 307fa032ad chore: restore workspace:* deps on main 2026-06-01 17:09:59 +00:00
xiaoju 46d7991002 chore: merge release v0.1.0 2026-06-01 17:09:41 +00:00
77 changed files with 5856 additions and 5866 deletions
+2 -2
View File
@@ -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
+12 -3
View File
@@ -32,6 +32,7 @@ ocas verify <hash> # check integrity + schema validity
ocas refs <hash> # list direct ocas_ref edges
ocas walk <hash> # recursive DAG traversal
ocas list --type <hash|name> # list nodes by type
ocas list --tag <expr> # filter by tag
ocas list-schema # list all schema hashes
ocas list-meta # list meta-schema hashes
ocas gc # garbage collection
@@ -40,14 +41,22 @@ ocas gc # garbage collection
### [[Variable]] Management
```bash
ocas var set <name> <hash> [--tag key:value] [--tag label]
ocas var set <name> <hash>
ocas var get <name> --schema <hash>
ocas var delete <name> [--schema <hash>]
ocas var list [prefix] [--schema <hash>] [--tag ...]
ocas var tag <name> --schema <hash> <operations...>
ocas var list [prefix] [--schema <hash>]
ocas var history <name> [--schema <hash>]
```
### Tagging
```bash
ocas tag <target> <tag>... # attach tags (key:value) or labels (bare)
ocas untag <target> <tag>... # remove tags by key or label
```
Tags apply to any target (variable or node). `ocas get` and `ocas var get` include tag info in output.
### [[Render System|Template & Render]]
```bash
+17 -13
View File
@@ -13,16 +13,24 @@ The Store is the abstract storage interface at the heart of OCAS. All operations
```typescript
type Store = {
put(typeHash: Hash, payload: unknown): Promise<Hash>;
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
listAll(): Hash[];
listByType(typeHash: Hash, options?: ListOptions): ListEntry[];
listMeta(options?: ListOptions): ListEntry[];
listSchemas(options?: ListOptions): ListEntry[];
cas: CasStore;
var: VarStore;
tag: TagStore;
};
```
### CasStore
Content-addressed storage. Handles `put`, `get`, `has`, and list operations over CAS nodes.
### VarStore
Mutable name → hash bindings (variables). Replaces the old standalone `VariableStore`.
### TagStore
Tag-based grouping and querying of CAS nodes.
### ListOptions & ListEntry
List methods accept optional `ListOptions` for sorting and pagination:
@@ -43,16 +51,12 @@ When `limit` is `undefined`, all results are returned (no cap). The [[CLI]] defa
In-memory `Map<Hash, CasNode>`. Used in tests and for ephemeral computation (e.g. computing a hash without persisting). Created via `createMemoryStore()`.
### FsStore
### FsStore (`@ocas/fs`)
Filesystem-backed store (`@ocas/fs`). Nodes are serialized as CBOR files under a content-addressed directory tree. Created via `openStore(path)`, which:
Filesystem-backed store. 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.
+9 -5
View File
@@ -41,18 +41,22 @@ The `@scope` prefix ensures variable names are visually distinct from 13-charact
## Operations
```bash
ocas var set <name> <hash> [--tag env:prod] [--tag pinned]
ocas var set <name> <hash>
ocas var get <name> --schema <hash>
ocas var delete <name> [--schema <hash>]
ocas var list [prefix] [--schema <hash>] [--tag env:prod]
ocas var tag <name> --schema <hash> status:active pinned :archived
ocas var list [prefix] [--schema <hash>]
```
`var set` is an upsert — creates or updates. Name prefix filtering replaces the old scope concept: `var list myapp/` returns all variables under `myapp/`.
`var set` is an upsert — creates or updates. Name prefix filtering replaces the old scope concept: `var list myapp/` returns all variables under `myapp/`. Tagging is now handled by the top-level `ocas tag` / `ocas untag` commands (see [[CLI]]).
## Tags and Labels
Tags and labels share a unified command (`var tag`):
Tags and labels are managed via top-level `ocas tag` / `ocas untag` commands:
```bash
ocas tag <target> env:prod pinned # set tag + label
ocas untag <target> env pinned # remove by key/label
```
- **Tags** are `key:value` pairs — same-key mutually exclusive (e.g. `env:prod` replaces `env:dev`)
- **Labels** are bare strings (e.g. `pinned`, `important`)
+60 -38
View File
@@ -58,8 +58,7 @@ bun run format # Biome format (auto-fix)
- `Hash` — 13-character uppercase Crockford Base32 string (XXH64)
- `CasNode` — content-addressed node with schema
- `Store`abstract storage interface (get/put)
- `VariableStore` — SQLite-backed mutable bindings (name → hash)
- `Store`unified storage interface `{ cas: CasStore, var: VarStore, tag: TagStore }`
- `ListOptions` — sorting/pagination options (`sort`, `desc`, `limit`, `offset`)
- `ListEntry` — list result entry (`hash`, `created`, `updated`)
@@ -69,11 +68,11 @@ bun run format # Biome format (auto-fix)
### Architecture Notes
- **No "alias" concept** — every name resolution flows through the `VariableStore`. Builtin schemas (`@ocas/schema`, `@ocas/string`, `@ocas/output/*`, …) are registered as variables during `bootstrap(store, varStore)`, alongside user-defined variables created via `ocas var set`.
- **`bootstrap(store, varStore?)`** writes builtin name → hash bindings into the varStore when one is provided; called automatically by `openStore()` and `openStoreAndVarStore()`.
- **`resolveHash(input, varStore)`** is the unified hash/name resolver in the CLI. If `input` matches the 13-char hash format it is returned as-is; otherwise the varStore is queried by exact name. This means every CLI command that accepts a hash argument also accepts a variable name (schema names, user vars, etc.).
- **No "alias" concept** — every name resolution flows through `store.var`. Builtin schemas (`@ocas/schema`, `@ocas/string`, `@ocas/output/*`, …) are registered as variables during `bootstrap(store)`, alongside user-defined variables created via `ocas var set`.
- **`bootstrap(store)`** synchronously writes builtin name → hash bindings into the unified store; called automatically by `openStore()`.
- **`resolveHash(input, store)`** is the unified hash/name resolver in the CLI. If `input` matches the 13-char hash format it is returned as-is; otherwise `store.var` is queried by exact name. This means every CLI command that accepts a hash argument also accepts a variable name (schema names, user vars, etc.).
- **Variable naming**: all names must follow `@scope/name` format (`@[a-zA-Z][a-zA-Z0-9]*/segments`). `@ocas/*` is reserved for builtins. The `@` prefix ensures names are visually distinct from hashes.
- **`openStoreAndVarStore()`** in the CLI opens both stores and bootstraps once; prefer it over separate `openStore()` + `createVariableStore()` to avoid double bootstrap.
- **`openStore()`** returns a unified `Store` with `cas`, `var`, and `tag` sub-stores, and bootstraps automatically. `@ocas/core` has zero `bun:sqlite` dependency.
### Internal Dependencies
@@ -98,42 +97,65 @@ This is resolved to real version numbers only during publishing (see below).
## Release Process
Releases use a **release branch** workflow. `main` always keeps `workspace:*` for
internal dependencies; version numbers are only fixed on the release branch.
Releases use a **release branch** workflow with three phases: prepare → candidate → finalize.
### Prepare
```bash
./scripts/prepare-release.sh
```
This script:
1. Checks you're on `main` with a clean tree and pending changesets
2. Creates `release/<version>` branch
3. Runs `changeset version` to fix versions and generate CHANGELOGs
4. Runs full validation (install, build, lint, test)
5. Commits the version bump
After preparation, review changes and fix any issues on the release branch.
### Publish
```bash
./scripts/publish.sh
```
This script:
1. Validates you're on a `release/*` branch with no pending changesets
2. Runs final build + test
3. Publishes packages in order: `@ocas/core``@ocas/fs``@ocas/cli`
4. Tags, pushes, merges back to `main`, cleans up the release branch
`main` always keeps `workspace:*` for internal deps; release branches fix them to real versions.
Changeset files are **only consumed once** during finalize — prerelease (rc) never touches them.
### Adding a Changeset
Before releasing, add changesets for your changes:
Add changesets alongside feature PRs on `main`:
```bash
bunx changeset # interactive — pick packages + bump type + summary
```markdown
<!-- .changeset/my-change.md -->
---
"@ocas/cli": patch
---
Description of the change
```
Changesets live in `.changeset/` as markdown files until consumed by `prepare-release.sh`.
Changesets live in `.changeset/` as markdown files. Bump types: `patch` / `minor` / `major`.
One changeset can cover multiple packages.
### Phase 1: Prepare (cut release branch)
- **Precondition:** on `main`, clean tree, `.changeset/` has pending changesets
- **Steps:**
1. Determine target version (from changeset bump types or manually)
2. `git checkout -b release/<version>`
3. Fix `workspace:*` → real version numbers in all `package.json`
4. Commit
- **Does NOT** run `changeset version`, does NOT write CHANGELOG
### Phase 2: Candidate (publish rc for validation)
- **Precondition:** on `release/*` branch
- **Steps:**
1. Set version to `<version>-rc.N` (first time rc.1, increment on subsequent runs)
2. `bun install && bun run build && bun test && bun run check`
3. Publish: `bun publish --tag rc` (order: core → fs → cli)
4. Commit + push
- **Repeatable:** fix bugs → add new changesets on the release branch → rc.N+1
- **Does NOT** consume changesets, does NOT write CHANGELOG
- Install for testing: `bun add -g @ocas/cli@rc`
### Phase 3: Finalize (official release)
- **Precondition:** on `release/*` branch, rc validated
- **Steps:**
1. Consume all `.changeset/*.md` → write CHANGELOG entries (use `changeset version` or manual)
2. Set final version `<version>` (remove `-rc.N`)
3. `bun install && bun run build && bun test && bun run check`
4. Publish: `bun publish --tag latest` (order: core → fs → cli)
5. Git tag `v<version>`
6. Merge back to `main` (CHANGELOG comes along)
7. Restore `workspace:*` on `main`
8. Delete release branch
### Key Rules
- **Publish order** is always `@ocas/core``@ocas/fs``@ocas/cli`
- **`workspace:*`** must be fixed before any publish — `bun publish` does NOT auto-replace them
- **CHANGELOG** only contains official releases, never rc entries
- **Changesets added on release branch** (bug fixes during rc) are consumed together at finalize
+6 -6
View File
@@ -15,18 +15,18 @@
},
"packages/cli": {
"name": "@ocas/cli",
"version": "0.6.0",
"version": "0.1.1",
"bin": {
"ocas": "src/index.ts",
},
"dependencies": {
"@ocas/core": "^0.6.0",
"@ocas/fs": "^0.6.0",
"@ocas/core": "0.1.1",
"@ocas/fs": "0.1.1",
},
},
"packages/core": {
"name": "@ocas/core",
"version": "0.6.0",
"version": "0.1.1",
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
@@ -36,9 +36,9 @@
},
"packages/fs": {
"name": "@ocas/fs",
"version": "0.6.0",
"version": "0.1.1",
"dependencies": {
"@ocas/core": "^0.6.0",
"@ocas/core": "0.1.1",
"cborg": "^4.2.3",
},
},
+42 -54
View File
@@ -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 -6
View File
@@ -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
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@ocas/cli",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"bin": {
"ocas": "src/index.ts"
@@ -10,8 +10,8 @@
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
},
"dependencies": {
"@ocas/core": "0.1.0",
"@ocas/fs": "0.1.0"
"@ocas/core": "0.2.0",
"@ocas/fs": "0.2.0"
},
"repository": {
"type": "git",
+398 -387
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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,19 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const usagePath = join(import.meta.dir, "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/);
});
});
+198
View File
@@ -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.
@@ -23,14 +23,15 @@ Commands:
hash <type-hash> <file.json|--pipe> Compute hash without storing (@ocas/output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-name> List hashes for a type (value=string[]) (@ocas/output/list)
list --type <hash-or-name> [--tag <tag>...] List hashes for a type, optionally filtered by tags (@ocas/output/list)
list-meta List meta-schema hashes (value=string[]) (@ocas/output/list-meta)
list-schema List all schema hashes (value=string[]) (@ocas/output/list-schema)
list-schema List all schema hashes (value=string[]) (@ocas/output/list-schema)
tag <target> <tag>... Apply tags/labels to a target (@ocas/output/tag)
untag <target> <tag>... Remove tags/labels from a target (@ocas/output/untag)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@ocas/output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@ocas/output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@ocas/output/var-tag)
var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history)
template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set)
template get <schema-hash> Get template content (value=string) (@ocas/output/template-get)
@@ -40,7 +41,6 @@ Commands:
Flags:
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--render, -r Render output inline (equivalent to | ocas render -p)
--schema <hash> Schema hash filter for var get/delete/tag/list
@@ -49,7 +49,9 @@ Flags:
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)"
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)
Agent: If you have not installed the ocas skill for this version (0.2.0), run \`ocas prompt setup\` and follow the instructions."
`;
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
@@ -67,7 +69,7 @@ exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "7C75FQT98KKQD",
"type": "F5RRJTXP8Z99D",
"value": {
"labels": [],
"name": "@myapp/config",
@@ -84,52 +86,10 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"value": [
{
"labels": [],
"name": "@ocas/schema",
"schema": "CTS5P6RD8HMCS",
"name": "@myapp/config",
"schema": "FRBAB1BF0ZBCS",
"tags": {},
"value": "CTS5P6RD8HMCS",
},
{
"labels": [],
"name": "@ocas/string",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "7VQ43ZSJTEWA7",
},
{
"labels": [],
"name": "@ocas/number",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "BEAZQGKVXMZT8",
},
{
"labels": [],
"name": "@ocas/integer",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "B26JM4PBHPAFK",
},
{
"labels": [],
"name": "@ocas/boolean",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "1AVHCXEJVDCPP",
},
{
"labels": [],
"name": "@ocas/bool",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "1AVHCXEJVDCPP",
},
{
"labels": [],
"name": "@ocas/object",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "944RT37WX1PQ5",
"value": "9W3MGR3184QYE",
},
{
"labels": [],
@@ -138,6 +98,27 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"tags": {},
"value": "D45CW047XS17Y",
},
{
"labels": [],
"name": "@ocas/bool",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "1AVHCXEJVDCPP",
},
{
"labels": [],
"name": "@ocas/boolean",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "1AVHCXEJVDCPP",
},
{
"labels": [],
"name": "@ocas/integer",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "B26JM4PBHPAFK",
},
{
"labels": [],
"name": "@ocas/null",
@@ -147,17 +128,31 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
},
{
"labels": [],
"name": "@ocas/output/put",
"name": "@ocas/number",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "4ZHWK21APCFZ5",
"value": "BEAZQGKVXMZT8",
},
{
"labels": [],
"name": "@ocas/object",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "944RT37WX1PQ5",
},
{
"labels": [],
"name": "@ocas/output/gc",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "7KHZTY010988K",
},
{
"labels": [],
"name": "@ocas/output/get",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "FB4K0SXG68ZFS",
"value": "7V5G8E2VW8B2G",
},
{
"labels": [],
@@ -173,27 +168,6 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"tags": {},
"value": "1B24CBF95Q5G6",
},
{
"labels": [],
"name": "@ocas/output/verify",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "52HEFB52BD0GF",
},
{
"labels": [],
"name": "@ocas/output/refs",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "2TKP4RGBJ4V43",
},
{
"labels": [],
"name": "@ocas/output/walk",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "4HG6MD3XG5H5C",
},
{
"labels": [],
"name": "@ocas/output/list",
@@ -217,52 +191,31 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
},
{
"labels": [],
"name": "@ocas/output/var-set",
"name": "@ocas/output/put",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "0Q5EMYK4SYSS9",
"value": "4ZHWK21APCFZ5",
},
{
"labels": [],
"name": "@ocas/output/var-get",
"name": "@ocas/output/refs",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "7C75FQT98KKQD",
"value": "2TKP4RGBJ4V43",
},
{
"labels": [],
"name": "@ocas/output/var-delete",
"name": "@ocas/output/tag",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "C3MYPR5RGQFZT",
"value": "CPSWA9TB2JMWP",
},
{
"labels": [],
"name": "@ocas/output/var-tag",
"name": "@ocas/output/template-delete",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "9103EYRMM949A",
},
{
"labels": [],
"name": "@ocas/output/var-list",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "AF0XACGXHPMC1",
},
{
"labels": [],
"name": "@ocas/output/var-history",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "EVZJS80TRFKE1",
},
{
"labels": [],
"name": "@ocas/output/template-set",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "BJDHPAE4Q8TXM",
"value": "BY7BGZJND3N7R",
},
{
"labels": [],
@@ -280,24 +233,80 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
},
{
"labels": [],
"name": "@ocas/output/template-delete",
"name": "@ocas/output/template-set",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "BY7BGZJND3N7R",
"value": "BJDHPAE4Q8TXM",
},
{
"labels": [],
"name": "@ocas/output/gc",
"name": "@ocas/output/untag",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "7KHZTY010988K",
"value": "BPEQMRQNJK80Z",
},
{
"labels": [],
"name": "@myapp/config",
"schema": "FRBAB1BF0ZBCS",
"name": "@ocas/output/var-delete",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "9W3MGR3184QYE",
"value": "C3MYPR5RGQFZT",
},
{
"labels": [],
"name": "@ocas/output/var-get",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "F5RRJTXP8Z99D",
},
{
"labels": [],
"name": "@ocas/output/var-history",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "EVZJS80TRFKE1",
},
{
"labels": [],
"name": "@ocas/output/var-list",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "AF0XACGXHPMC1",
},
{
"labels": [],
"name": "@ocas/output/var-set",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "0Q5EMYK4SYSS9",
},
{
"labels": [],
"name": "@ocas/output/verify",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "52HEFB52BD0GF",
},
{
"labels": [],
"name": "@ocas/output/walk",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "4HG6MD3XG5H5C",
},
{
"labels": [],
"name": "@ocas/schema",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "CTS5P6RD8HMCS",
},
{
"labels": [],
"name": "@ocas/string",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "7VQ43ZSJTEWA7",
},
],
}
@@ -331,9 +340,9 @@ exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1
}
`;
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
exports[`Phase 3: Variable System 3.6 var set with tag and label adds them 1`] = `
{
"type": "9103EYRMM949A",
"type": "0Q5EMYK4SYSS9",
"value": {
"labels": [
"important",
@@ -386,9 +395,9 @@ exports[`Phase 3: Variable System 3.8 var list --tag important filters by label
}
`;
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
exports[`Phase 3: Variable System 3.9 var set without label removes it 1`] = `
{
"type": "9103EYRMM949A",
"type": "0Q5EMYK4SYSS9",
"value": {
"labels": [],
"name": "@myapp/config",
@@ -2,7 +2,7 @@
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"type": "FB4K0SXG68ZFS",
"type": "7V5G8E2VW8B2G",
"value": {
"payload": {
"age": 30,
+5 -5
View File
@@ -67,7 +67,7 @@ function envValue(json: string): unknown {
}
describe("@ Alias Resolution - put", () => {
test("ocas put @string <file> should resolve alias", async () => {
test("ocas put @ocas/string <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
@@ -85,7 +85,7 @@ describe("@ Alias Resolution - put", () => {
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ocas put @number <file> should resolve alias", async () => {
test("ocas put @ocas/number <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
@@ -101,7 +101,7 @@ describe("@ Alias Resolution - put", () => {
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ocas put @object <file> should resolve alias", async () => {
test("ocas put @ocas/object <file> should resolve alias", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
@@ -133,7 +133,7 @@ describe("@ Alias Resolution - put", () => {
expect(stderr.length).toBeGreaterThan(0);
});
test("ocas put @schema with nested type constraints should succeed", async () => {
test("ocas put @ocas/schema with nested type constraints should succeed", async () => {
await runCliAlias("init");
const schemaFile = join(testDir, "constrained-schema.json");
@@ -168,7 +168,7 @@ describe("@ Alias Resolution - put", () => {
});
describe("@ Alias Resolution - hash", () => {
test("ocas hash @string <file> should compute hash without storing", async () => {
test("ocas hash @ocas/string <file> should compute hash without storing", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
+32 -51
View File
@@ -18,7 +18,7 @@ describe("ocas binary", () => {
test("T2: no legacy bin entries (json-cas, ucas)", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin["json-cas"]).toBeUndefined();
expect(pkg.bin["ucas"]).toBeUndefined();
expect(pkg.bin.ucas).toBeUndefined();
expect(Object.keys(pkg.bin)).toEqual(["ocas"]);
});
@@ -38,17 +38,16 @@ 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 proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...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();
@@ -57,7 +56,6 @@ describe("Phase 7: Edge Cases", () => {
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
@@ -72,10 +70,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 }));
@@ -134,16 +129,7 @@ describe("Phase 7: Edge Cases", () => {
const fileAsStore = join(tmpStore, "not-a-directory");
writeFileSync(fileAsStore, "test");
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--home",
fileAsStore,
"--var-db",
varDbPath,
"get",
"AAAAAAAAAAAAA",
],
["bun", entrypoint, "--home", fileAsStore, "get", "AAAAAAAAAAAAA"],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
@@ -157,17 +143,16 @@ 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 proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...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();
@@ -176,7 +161,6 @@ describe("Phase 3: Variable System", () => {
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
@@ -191,10 +175,7 @@ describe("Phase 3: Variable System", () => {
const { openStore: openFsStore } = await import("@ocas/fs");
const { putSchema } = await import("@ocas/core");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
typeHash = putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
@@ -233,7 +214,11 @@ describe("Phase 3: Variable System", () => {
test("3.3 var list shows all variables", async () => {
const { stdout, exitCode } = await runCli(["var", "list"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
const stripped = stripVolatile(stdout) as { value: { name: string }[] };
stripped.value.sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
expect(stripped).toMatchSnapshot();
expect(stdout).toContain("@myapp/config");
});
@@ -261,14 +246,15 @@ describe("Phase 3: Variable System", () => {
await runCli(["var", "set", "@myapp/config", nodeHash]);
});
test("3.6 var tag adds kv tag and label", async () => {
test("3.6 var set with tag and label adds them", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"set",
"@myapp/config",
"--schema",
typeHash,
nodeHash,
"--tag",
"env:prod",
"--tag",
"important",
]);
expect(exitCode).toBe(0);
@@ -299,14 +285,14 @@ describe("Phase 3: Variable System", () => {
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.9 var tag remove deletes label", async () => {
test("3.9 var set without label removes it", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"set",
"@myapp/config",
"--schema",
typeHash,
":important",
nodeHash,
"--tag",
"env:prod",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
@@ -347,16 +333,15 @@ 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 proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...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();
@@ -365,7 +350,6 @@ describe("Phase 4: Template System", () => {
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
@@ -380,10 +364,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(() => {
+6 -20
View File
@@ -7,13 +7,11 @@ import { envValue } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
@@ -28,10 +26,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 }));
@@ -49,10 +44,10 @@ afterAll(() => {
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 proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...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();
@@ -81,16 +76,7 @@ describe("Phase 6: GC", () => {
expect(gcExit).toBe(0);
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--home",
tmpStore,
"--var-db",
varDbPath,
"render",
"--pipe",
],
["bun", entrypoint, "--home", tmpStore, "render", "--pipe"],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(gcOut);
+269
View File
@@ -0,0 +1,269 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash } from "@ocas/core";
import { bootstrap, validate } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
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.dir, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// ignore
}
});
async function runCli(...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,
};
}
async function createTestNode(value = "hello-world"): Promise<Hash> {
const store = await openFsStore(storePath);
const aliases = bootstrap(store);
const typeHash = aliases["@ocas/string"];
if (!typeHash) throw new Error("@ocas/string not found");
return store.cas.put(typeHash, value);
}
describe("get/var-get with tags", () => {
test("G1: ocas get on untagged node — output unchanged (no tags field)", async () => {
const hash = await createTestNode("hello");
const { stdout, exitCode } = await runCli("get", hash);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(typeof envelope.value.type).toBe("string");
expect(envelope.value.payload).toBe("hello");
expect(typeof envelope.value.timestamp).toBe("number");
expect("tags" in envelope.value).toBe(false);
});
test("G2: ocas get on tagged node — includes tags array", async () => {
const hash = await createTestNode("tagged");
await runCli("tag", hash, "env:prod", "stable", "owner:alice");
const { stdout, exitCode } = await runCli("get", hash);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.payload).toBe("tagged");
expect(typeof envelope.value.timestamp).toBe("number");
expect(Array.isArray(envelope.value.tags)).toBe(true);
expect(envelope.value.tags).toHaveLength(3);
const pairs = new Set(
envelope.value.tags.map(
(t: { key: string; value: string | null }) => `${t.key}=${t.value}`,
),
);
expect(pairs.has("env=prod")).toBe(true);
expect(pairs.has("stable=null")).toBe(true);
expect(pairs.has("owner=alice")).toBe(true);
for (const t of envelope.value.tags) {
expect(t.target).toBe(hash);
expect(typeof t.created).toBe("number");
}
});
test("G3: ocas get after untag removes all tags — no tags key (empty array not serialized)", async () => {
const hash = await createTestNode("u3");
await runCli("tag", hash, "k:v");
await runCli("untag", hash, "k");
const { stdout, exitCode } = await runCli("get", hash);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect("tags" in envelope.value).toBe(false);
});
test("V1: ocas var get when value hash untagged — output unchanged (no valueTags)", async () => {
const hash = await createTestNode("hello");
await runCli("var", "set", "@user/foo", hash);
const { stdout, exitCode } = await runCli(
"var",
"get",
"@user/foo",
"--schema",
"@ocas/string",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.name).toBe("@user/foo");
expect(envelope.value.value).toBe(hash);
expect(envelope.value.tags).toEqual({});
expect(envelope.value.labels).toEqual([]);
expect("valueTags" in envelope.value).toBe(false);
});
test("V2: ocas var get when value hash tagged — includes valueTags array", async () => {
const hash = await createTestNode("hello");
await runCli("var", "set", "@user/foo", hash);
await runCli("tag", hash, "env:prod", "stable");
const { stdout, exitCode } = await runCli(
"var",
"get",
"@user/foo",
"--schema",
"@ocas/string",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.tags).toEqual({});
expect(envelope.value.labels).toEqual([]);
expect(Array.isArray(envelope.value.valueTags)).toBe(true);
expect(envelope.value.valueTags).toHaveLength(2);
const pairs = new Set(
envelope.value.valueTags.map(
(t: { key: string; value: string | null }) => `${t.key}=${t.value}`,
),
);
expect(pairs.has("env=prod")).toBe(true);
expect(pairs.has("stable=null")).toBe(true);
for (const t of envelope.value.valueTags) {
expect(t.target).toBe(hash);
expect(typeof t.created).toBe("number");
}
});
test("V3: variable tags vs valueTags don't collide", async () => {
const hash = await createTestNode("hello");
await runCli("var", "set", "@user/foo", hash, "--tag", "a:1", "--tag", "x");
await runCli("tag", hash, "owner:bob");
const { stdout, exitCode } = await runCli(
"var",
"get",
"@user/foo",
"--schema",
"@ocas/string",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.tags).toEqual({ a: "1" });
expect(envelope.value.labels).toEqual(["x"]);
expect(Array.isArray(envelope.value.valueTags)).toBe(true);
expect(envelope.value.valueTags).toHaveLength(1);
expect(envelope.value.valueTags[0]).toMatchObject({
key: "owner",
value: "bob",
target: hash,
});
});
test("S1: @ocas/output/get schema validates with and without tags", async () => {
const store = await openFsStore(storePath);
const aliases = bootstrap(store);
const getSchemaHash = aliases["@ocas/output/get"];
if (!getSchemaHash) throw new Error("schema not found");
const stringHash = aliases["@ocas/string"];
if (!stringHash) throw new Error("string schema not found");
const untagged = {
type: getSchemaHash,
payload: { type: stringHash, payload: "hi", timestamp: 1 },
timestamp: 2,
};
expect(validate(store, untagged)).toBe(true);
const tagged = {
type: getSchemaHash,
payload: {
type: stringHash,
payload: "hi",
timestamp: 1,
tags: [
{
key: "env",
value: "prod",
target: stringHash,
created: 123,
},
],
},
timestamp: 2,
};
expect(validate(store, tagged)).toBe(true);
});
test("S2: @ocas/output/var-get schema validates with and without valueTags", async () => {
const store = await openFsStore(storePath);
const aliases = bootstrap(store);
const varGetSchemaHash = aliases["@ocas/output/var-get"];
if (!varGetSchemaHash) throw new Error("schema not found");
const stringHash = aliases["@ocas/string"];
if (!stringHash) throw new Error("string schema not found");
const untagged = {
type: varGetSchemaHash,
payload: {
name: "@user/foo",
schema: stringHash,
value: stringHash,
created: 1,
updated: 2,
tags: {},
labels: [],
},
timestamp: 3,
};
expect(validate(store, untagged)).toBe(true);
const tagged = {
type: varGetSchemaHash,
payload: {
name: "@user/foo",
schema: stringHash,
value: stringHash,
created: 1,
updated: 2,
tags: {},
labels: [],
valueTags: [
{
key: "env",
value: "prod",
target: stringHash,
created: 123,
},
],
},
timestamp: 3,
};
expect(validate(store, tagged)).toBe(true);
});
});
+1 -1
View File
@@ -42,7 +42,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;
}
+6 -6
View File
@@ -19,7 +19,7 @@ afterEach(() => {
describe("list-meta CLI command", () => {
test("E1. list-meta on bootstrapped store contains exactly the meta-schema hash", async () => {
// First, get @schema hash by calling has on it (also triggers bootstrap)
// First, get @ocas/schema hash by calling has on it (also triggers bootstrap)
const { stdout: hashOut, exitCode: hashCode } = await runCli(
["hash", "@ocas/schema", "--pipe"],
storePath,
@@ -28,7 +28,7 @@ describe("list-meta CLI command", () => {
void hashOut;
void hashCode;
// Bootstrap fully via 'list --type @schema'
// Bootstrap fully via 'list --type @ocas/schema'
const { stdout: schemaListOut } = await runCli(
["list", "--type", "@ocas/schema"],
storePath,
@@ -119,19 +119,19 @@ describe("F1. output schemas registered", () => {
});
describe("E4. list-schema vs list --type with multiple meta-schema versions", () => {
test("list-schema includes schemas typed by older meta-schemas; list --type @schema does not", async () => {
test("list-schema includes schemas typed by older meta-schemas; list --type @ocas/schema does not", async () => {
// Set up two distinct meta-schemas via the library
const store = await openFsStore(storePath);
const m1 = await store[BOOTSTRAP_STORE]({
const m1 = await store.cas[BOOTSTRAP_STORE]({
type: "object",
title: "meta-v1",
});
const m2 = await store[BOOTSTRAP_STORE]({
const m2 = await store.cas[BOOTSTRAP_STORE]({
type: "object",
title: "meta-v2",
});
// schema typed by older meta M1
const sM1 = await store.put(m1, { type: "string" });
const sM1 = store.cas.put(m1, { type: "string" });
expect(m1).not.toBe(m2);
// CLI: list-schema must include sM1
+369
View File
@@ -0,0 +1,369 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
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.dir, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// ignore
}
});
async function runCli(...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,
};
}
async function putString(value: string): Promise<string> {
const proc = Bun.spawn(
[
"bun",
"run",
cliPath,
"--home",
storePath,
"put",
"@ocas/string",
"--pipe",
],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(JSON.stringify(value));
await proc.stdin.end();
const out = await new Response(proc.stdout).text();
const err = await new Response(proc.stderr).text();
await proc.exited;
if ((proc.exitCode ?? 0) !== 0) {
throw new Error(`put failed: ${err}`);
}
return JSON.parse(out.trim()).value as string;
}
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([]);
});
});
+15 -19
View File
@@ -2,18 +2,16 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { envValue, stripVolatile } from "./helpers";
import { envValue } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
let tmpStore: string;
let varDbPath: string;
let typeHash: string;
let nodeHash: string;
let _nodeHash: string;
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
@@ -28,15 +26,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");
@@ -51,10 +46,10 @@ afterAll(() => {
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 proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...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();
@@ -65,10 +60,11 @@ async 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" },
);
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
@@ -101,7 +97,7 @@ describe("Phase 8: Pipe Composition", () => {
expect(stdout).toContain("Bob");
});
test("8.3 list --type @schema emits a parseable envelope of hashes", async () => {
test("8.3 list --type @ocas/schema emits a parseable envelope of hashes", async () => {
const { stdout, exitCode } = await runCli([
"list",
"--type",
@@ -117,7 +113,7 @@ describe("Phase 8: Pipe Composition", () => {
}
});
test("8.4 list --type @schema | render -p expands the schema list", async () => {
test("8.4 list --type @ocas/schema | render -p expands the schema list", async () => {
const { stdout: listOut } = await runCli([
"list",
"--type",
+6 -11
View File
@@ -7,13 +7,11 @@ import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
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 +29,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 }));
@@ -49,10 +44,10 @@ afterAll(() => {
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 proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...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();
@@ -60,7 +55,7 @@ async function runCli(
}
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}$/);
});
+20 -24
View File
@@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { bootstrap } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { openStore as openFsStore } from "@ocas/fs";
import { envValue, putSchemaFile, runCli, runCliWithStdin } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
@@ -54,31 +54,31 @@ 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 proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...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 };
}
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" },
);
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
@@ -89,7 +89,6 @@ describe("Phase 5: Render", () => {
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
@@ -104,10 +103,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 +194,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 +317,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 +417,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 +452,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
+6 -41
View File
@@ -556,15 +556,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");
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
@@ -579,30 +577,17 @@ 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,
],
["bun", entrypoint, "--home", tmpStore, "put", typeHash, nodeFile],
{ stdout: "pipe", stderr: "pipe" },
);
await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
nodeHash = envValue(stdout) as string;
_nodeHash = envValue(stdout) as string;
});
afterAll(() => {
@@ -613,17 +598,7 @@ describe("Phase 2: Schema Validation", () => {
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,
],
["bun", entrypoint, "--home", tmpStore, "put", typeHash, badFile],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
@@ -638,17 +613,7 @@ 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,
],
["bun", entrypoint, "--home", tmpStore, "put", "AAAAAAAAAAAAA", nodeFile],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
+235
View File
@@ -0,0 +1,235 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash } from "@ocas/core";
import { bootstrap } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
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.dir, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// ignore
}
});
async function runCli(...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,
};
}
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);
});
});
+30 -41
View File
@@ -4,13 +4,12 @@ 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,7 +19,6 @@ 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");
mkdirSync(testDir, { recursive: true });
@@ -45,16 +43,7 @@ async function runCli(...args: string[]): Promise<{
exitCode: number;
}> {
const proc = Bun.spawn(
[
"bun",
"run",
cliPath,
"--home",
storePath,
"--var-db",
varDbPath,
...args,
],
["bun", "run", cliPath, "--home", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
@@ -76,10 +65,10 @@ async function runCli(...args: string[]): Promise<{
}
/**
* Get bootstrap @string type hash
* Get bootstrap @ocas/string type hash
*/
async function getStringHash(store: Store): Promise<Hash> {
const builtinSchemas = await bootstrap(store);
const builtinSchemas = bootstrap(store);
return builtinSchemas["@ocas/string"] ?? "";
}
@@ -87,7 +76,7 @@ async function getStringHash(store: Store): Promise<Hash> {
describe("template set", () => {
test("set template from file", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const templateFile = join(testDir, "template.txt");
@@ -110,7 +99,7 @@ describe("template set", () => {
});
test("set template with --inline flag", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const { stdout, exitCode } = await runCli(
@@ -130,7 +119,7 @@ describe("template set", () => {
});
test("update existing template (idempotent)", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const templateFile = join(testDir, "template.txt");
@@ -159,7 +148,7 @@ describe("template set", () => {
});
test("error when file not found", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli(
@@ -189,7 +178,7 @@ describe("template set", () => {
});
test("error when both file and --inline provided", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const templateFile = join(testDir, "template.txt");
@@ -209,7 +198,7 @@ describe("template set", () => {
});
test("support multi-line templates", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const multilineContent = "Line 1\nLine 2\nLine 3";
@@ -229,7 +218,7 @@ describe("template set", () => {
});
test("support empty templates", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const { stdout, exitCode } = await runCli(
@@ -247,7 +236,7 @@ describe("template set", () => {
});
test("error when neither file nor --inline provided", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli("template", "set", stringHash);
@@ -257,7 +246,7 @@ describe("template set", () => {
});
test("support templates with special characters", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const specialContent = "Template with {{var}} and $env and @ref";
@@ -279,7 +268,7 @@ describe("template set", () => {
describe("template get", () => {
test("retrieve template as envelope value", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const content = "Hello {{name}}!";
@@ -299,7 +288,7 @@ describe("template get", () => {
});
test("error when template not found", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli("template", "get", stringHash);
@@ -310,7 +299,7 @@ describe("template get", () => {
});
test("preserve exact whitespace", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
// The envelope's value preserves exact whitespace (JSON-escaped),
@@ -324,7 +313,7 @@ describe("template get", () => {
});
test("support multi-line templates", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const multiline = "Line 1\nLine 2\nLine 3";
@@ -338,7 +327,7 @@ describe("template get", () => {
describe("template list", () => {
test("list all templates", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
// Create multiple templates
@@ -361,7 +350,7 @@ describe("template list", () => {
});
test("entry contentHash matches set result", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const { stdout: setOut } = await runCli(
@@ -397,14 +386,14 @@ describe("template list", () => {
});
test("exclude non-template variables", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
// Create a template
await runCli("template", "set", stringHash, "--inline", "Template");
// Create a regular variable (not under @ocas/template/text/)
const hash = await store.put(stringHash, "regular var content");
const hash = store.cas.put(stringHash, "regular var content");
await runCli("var", "set", "regular/var", hash);
const { stdout } = await runCli("template", "list");
@@ -417,7 +406,7 @@ describe("template list", () => {
});
test("output JSON envelope with array value", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Test");
@@ -434,7 +423,7 @@ describe("template list", () => {
describe("template delete", () => {
test("delete template variable binding", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Template");
@@ -463,7 +452,7 @@ describe("template delete", () => {
});
test("error when template not found", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli("template", "delete", stringHash);
@@ -474,7 +463,7 @@ describe("template delete", () => {
});
test("deletion does not affect other templates", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
// Create two templates
@@ -497,7 +486,7 @@ describe("template delete", () => {
});
test("CAS content remains after variable deletion", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Content");
@@ -521,7 +510,7 @@ describe("template delete", () => {
});
test("deletion is non-idempotent (second delete fails)", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Template");
@@ -546,7 +535,7 @@ describe("template delete", () => {
describe("template integration", () => {
test("end-to-end workflow: set→get→list→delete", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
const content = "Integration test template";
@@ -593,7 +582,7 @@ describe("template integration", () => {
});
test("templates compatible with generic var commands", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
// Set via template command
@@ -607,7 +596,7 @@ describe("template integration", () => {
});
test("multiple templates for different schemas", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const stringHash = await getStringHash(store);
// Create templates for different schemas
+5 -16
View File
@@ -4,11 +4,10 @@ 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,7 +16,6 @@ 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");
mkdirSync(testDir, { recursive: true });
@@ -38,16 +36,7 @@ async function runCli(...args: string[]): Promise<{
exitCode: number;
}> {
const proc = Bun.spawn(
[
"bun",
"run",
cliPath,
"--home",
storePath,
"--var-db",
varDbPath,
...args,
],
["bun", "run", cliPath, "--home", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
@@ -72,12 +61,12 @@ 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 };
}
+105 -311
View File
@@ -4,13 +4,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { openStore as openFsStore } from "@ocas/fs";
// ---- Test helpers ----
let testDir: string;
let storePath: string;
let varDbPath: string;
let cliPath: string;
beforeEach(() => {
@@ -20,7 +19,6 @@ 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");
mkdirSync(testDir, { recursive: true });
@@ -45,16 +43,7 @@ async function runCli(...args: string[]): Promise<{
exitCode: number;
}> {
const proc = Bun.spawn(
[
"bun",
"run",
cliPath,
"--home",
storePath,
"--var-db",
varDbPath,
...args,
],
["bun", "run", cliPath, "--home", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
@@ -83,14 +72,14 @@ async function createTestNode(
typeHash: Hash,
payload: unknown,
): Promise<Hash> {
return await store.put(typeHash, payload);
return store.cas.put(typeHash, payload);
}
/**
* Get bootstrap type hash
*/
async function getBootstrapHash(store: Store): Promise<Hash> {
const builtinSchemas = await bootstrap(store);
const builtinSchemas = bootstrap(store);
return builtinSchemas["@ocas/schema"] ?? "";
}
@@ -98,7 +87,7 @@ async function getBootstrapHash(store: Store): Promise<Hash> {
describe("var set", () => {
test("create new variable without tags/labels", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
@@ -125,7 +114,7 @@ describe("var set", () => {
});
test("create with tags", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
@@ -148,7 +137,7 @@ describe("var set", () => {
});
test("create with labels", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
@@ -171,7 +160,7 @@ describe("var set", () => {
});
test("update existing variable (same schema)", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
@@ -183,7 +172,12 @@ describe("var set", () => {
await new Promise((resolve) => setTimeout(resolve, 10));
// Update with hash2
const { stdout, exitCode } = await runCli("var", "set", "@test/config", hash2);
const { stdout, exitCode } = await runCli(
"var",
"set",
"@test/config",
hash2,
);
expect(exitCode).toBe(0);
@@ -193,9 +187,9 @@ describe("var set", () => {
});
test("create variant with different schema", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash1 = await getBootstrapHash(store);
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
const typeHash2 = putSchema(store, { title: "Test", type: "object" });
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
@@ -203,7 +197,12 @@ describe("var set", () => {
await runCli("var", "set", "@test/config", hash1);
// Create second variant with different schema
const { stdout, exitCode } = await runCli("var", "set", "@test/config", hash2);
const { stdout, exitCode } = await runCli(
"var",
"set",
"@test/config",
hash2,
);
expect(exitCode).toBe(0);
@@ -217,7 +216,7 @@ describe("var set", () => {
});
test("update with new tags replaces old tags", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
@@ -254,7 +253,7 @@ describe("var set", () => {
});
test("error on invalid name format", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
@@ -280,7 +279,7 @@ describe("var set", () => {
});
test("error on tag/label name conflict", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
@@ -302,7 +301,7 @@ describe("var set", () => {
describe("var get", () => {
test("retrieve existing variable by name + schema", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
@@ -326,7 +325,7 @@ describe("var get", () => {
});
test("error when variable not found", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const { stderr, exitCode } = await runCli(
@@ -353,9 +352,9 @@ describe("var get", () => {
});
test("distinguish variants by schema", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash1 = await getBootstrapHash(store);
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
const typeHash2 = putSchema(store, { title: "Test", type: "object" });
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
@@ -364,13 +363,25 @@ describe("var get", () => {
await runCli("var", "set", "@test/config", hash2);
// Get first variant
const result1 = await runCli("var", "get", "@test/config", "--schema", typeHash1);
const result1 = await runCli(
"var",
"get",
"@test/config",
"--schema",
typeHash1,
);
expect(result1.exitCode).toBe(0);
const envelope1 = JSON.parse(result1.stdout);
expect(envelope1.value.value).toBe(hash1);
// Get second variant
const result2 = await runCli("var", "get", "@test/config", "--schema", typeHash2);
const result2 = await runCli(
"var",
"get",
"@test/config",
"--schema",
typeHash2,
);
expect(result2.exitCode).toBe(0);
const envelope2 = JSON.parse(result2.stdout);
expect(envelope2.value.value).toBe(hash2);
@@ -379,9 +390,9 @@ describe("var get", () => {
describe("var delete", () => {
test("remove all schema variants", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash1 = await getBootstrapHash(store);
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
const typeHash2 = putSchema(store, { title: "Test", type: "object" });
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
@@ -399,17 +410,29 @@ describe("var delete", () => {
expect(envelope.value.length).toBe(2);
// Verify both are deleted
const result1 = await runCli("var", "get", "@test/config", "--schema", typeHash1);
const result1 = await runCli(
"var",
"get",
"@test/config",
"--schema",
typeHash1,
);
expect(result1.exitCode).toBe(1);
const result2 = await runCli("var", "get", "@test/config", "--schema", typeHash2);
const result2 = await runCli(
"var",
"get",
"@test/config",
"--schema",
typeHash2,
);
expect(result2.exitCode).toBe(1);
});
test("remove specific variant by schema", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash1 = await getBootstrapHash(store);
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
const typeHash2 = putSchema(store, { title: "Test", type: "object" });
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
const hash2 = await createTestNode(store, typeHash2, { test: "data2" });
@@ -432,16 +455,32 @@ describe("var delete", () => {
expect(envelope.value.schema).toBe(typeHash1);
// Verify first is deleted
const result1 = await runCli("var", "get", "@test/config", "--schema", typeHash1);
const result1 = await runCli(
"var",
"get",
"@test/config",
"--schema",
typeHash1,
);
expect(result1.exitCode).toBe(1);
// Verify second still exists
const result2 = await runCli("var", "get", "@test/config", "--schema", typeHash2);
const result2 = await runCli(
"var",
"get",
"@test/config",
"--schema",
typeHash2,
);
expect(result2.exitCode).toBe(0);
});
test("return empty array when name not found", async () => {
const { stdout, exitCode } = await runCli("var", "delete", "@test/nonexistent");
const { stdout, exitCode } = await runCli(
"var",
"delete",
"@test/nonexistent",
);
expect(exitCode).toBe(0);
@@ -466,7 +505,7 @@ describe("var delete", () => {
});
test("cascade delete tags and labels", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
@@ -492,7 +531,7 @@ describe("var delete", () => {
describe("var list", () => {
test("list all variables", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
@@ -515,7 +554,7 @@ describe("var list", () => {
});
test("filter by name prefix", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
@@ -538,13 +577,13 @@ describe("var list", () => {
});
test("filter by schema", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const bootstrapHash = await getBootstrapHash(store);
const typeHash1 = await putSchema(store, {
const typeHash1 = putSchema(store, {
title: "TypeA",
type: "object",
});
const typeHash2 = await putSchema(store, {
const typeHash2 = putSchema(store, {
title: "TypeB",
type: "object",
});
@@ -573,7 +612,7 @@ describe("var list", () => {
});
test("filter by tags (AND logic)", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
@@ -620,7 +659,7 @@ describe("var list", () => {
});
test("filter by labels", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
@@ -660,7 +699,7 @@ describe("var list", () => {
});
test("combined filters", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash1 = await getBootstrapHash(store);
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
const hash2 = await createTestNode(store, typeHash1, { test: "data2" });
@@ -713,235 +752,9 @@ describe("var list", () => {
});
});
describe("var tag", () => {
test("add new tag", async () => {
const store = createFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
// Create variable without tags
await runCli("var", "set", "@test/x", hash);
// Add tag
const { stdout, exitCode } = await runCli(
"var",
"tag",
"@test/x",
"--schema",
typeHash,
"env:prod",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.tags).toEqual({ env: "prod" });
});
test("update existing tag value", async () => {
const store = createFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
// Create variable with tag
await runCli("var", "set", "@test/x", hash, "--tag", "env:dev");
// Update tag
const { stdout, exitCode } = await runCli(
"var",
"tag",
"@test/x",
"--schema",
typeHash,
"env:prod",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.tags).toEqual({ env: "prod" });
});
test("add label", async () => {
const store = createFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
// Create variable without labels
await runCli("var", "set", "@test/x", hash);
// Add label
const { stdout, exitCode } = await runCli(
"var",
"tag",
"@test/x",
"--schema",
typeHash,
"stable",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.labels).toEqual(["stable"]);
});
test("delete tag", async () => {
const store = createFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
// Create variable with tags
await runCli(
"var",
"set",
"@test/x",
hash,
"--tag",
"env:prod",
"--tag",
"version:1.0",
);
// Delete tag
const { stdout, exitCode } = await runCli(
"var",
"tag",
"@test/x",
"--schema",
typeHash,
":env",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.tags).toEqual({ version: "1.0" });
});
test("delete label", async () => {
const store = createFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
// Create variable with labels
await runCli("var", "set", "@test/x", hash, "--tag", "stable", "--tag", "beta");
// Delete label
const { stdout, exitCode } = await runCli(
"var",
"tag",
"@test/x",
"--schema",
typeHash,
":stable",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.labels).toEqual(["beta"]);
});
test("mixed add and delete operations", async () => {
const store = createFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
// Create variable with tags and labels
await runCli("var", "set", "@test/x", hash, "--tag", "a:1", "--tag", "b");
// Mixed operations
const { stdout, exitCode } = await runCli(
"var",
"tag",
"@test/x",
"--schema",
typeHash,
"c:3",
":a",
"d",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.tags).toEqual({ c: "3" });
expect(envelope.value.labels.sort()).toEqual(["b", "d"]);
});
test("error on tag/label conflict", async () => {
const store = createFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
// Create variable with tag
await runCli("var", "set", "@test/x", hash, "--tag", "env:prod");
// Try to add same name as label
const { stderr, exitCode } = await runCli(
"var",
"tag",
"@test/x",
"--schema",
typeHash,
"env",
);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error: Conflict: 'env' already exists as a");
});
test("error when variable not found", async () => {
const store = createFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const { stderr, exitCode } = await runCli(
"var",
"tag",
"@test/nonexistent",
"--schema",
typeHash,
"env:prod",
);
expect(exitCode).toBe(1);
expect(stderr).toContain(
`Error: Variable not found: name=@test/nonexistent, schema=${typeHash}`,
);
});
test("error when --schema missing", async () => {
const { stderr, exitCode } = await runCli("var", "tag", "@test/x", "env:prod");
expect(exitCode).toBe(1);
expect(stderr).toContain(
"Usage: ocas var tag <name> --schema <hash-or-name> <operations...>",
);
});
test("error when no operations provided", async () => {
const store = createFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const { stderr, exitCode } = await runCli(
"var",
"tag",
"@test/x",
"--schema",
typeHash,
);
expect(exitCode).toBe(1);
expect(stderr).toContain(
"Usage: ocas var tag <name> --schema <hash-or-name> <operations...>",
);
});
});
describe("global options", () => {
test("--json flag for compact output", async () => {
const store = createFsStore(storePath);
const store = await openFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
@@ -965,7 +778,7 @@ describe("global options", () => {
const customStorePath = join(testDir, "custom-store");
mkdirSync(customStorePath, { recursive: true });
const store = createFsStore(customStorePath);
const store = await openFsStore(customStorePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
@@ -977,39 +790,6 @@ describe("global options", () => {
cliPath,
"--home",
customStorePath,
"--var-db",
varDbPath,
"var",
"set",
"@test/x",
hash,
],
{
stdout: "pipe",
stderr: "pipe",
},
);
await proc.exited;
expect(proc.exitCode).toBe(0);
});
test("--var-db flag for custom database path", async () => {
const customDbPath = join(testDir, "custom.db");
const store = createFsStore(storePath);
const typeHash = await getBootstrapHash(store);
const hash = await createTestNode(store, typeHash, { test: "data" });
// Override with custom db path
const proc = Bun.spawn(
[
"bun",
"run",
cliPath,
"--home",
storePath,
"--var-db",
customDbPath,
"var",
"set",
"@test/x",
@@ -1047,4 +827,18 @@ describe("old commands removed", () => {
expect(exitCode).toBe(1);
expect(stderr).toContain("Unknown var subcommand: update");
});
test("var tag subcommand removed", async () => {
const { stderr, exitCode } = await runCli(
"var",
"tag",
"@any/name",
"--schema",
"@ocas/string",
"foo",
);
expect(exitCode).toBe(1);
expect(stderr).toContain("Unknown var subcommand: tag");
});
});
+5 -10
View File
@@ -7,13 +7,11 @@ import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
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 +26,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,10 +41,10 @@ afterAll(() => {
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 proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...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();
+4 -2
View File
@@ -1,8 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"composite": false,
"declaration": false,
"declarationMap": false,
"noEmit": true
},
"include": ["src"]
}
+35 -42
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ocas/core",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
+4 -4
View File
@@ -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"
+44 -41
View File
@@ -18,9 +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/tag",
"@ocas/output/untag",
"@ocas/output/template-set",
"@ocas/output/template-get",
"@ocas/output/template-list",
@@ -33,11 +34,11 @@ const OUTPUT_ALIASES = [
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of 30 built-in schema aliases to hashes", async () => {
test("should return map of 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 + 21 output aliases = 30
// Should return object with 9 primitive + 22 output aliases = 31
expect(builtinSchemas).toHaveProperty("@ocas/schema");
expect(builtinSchemas).toHaveProperty("@ocas/string");
expect(builtinSchemas).toHaveProperty("@ocas/number");
@@ -52,7 +53,7 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(30);
expect(Object.keys(builtinSchemas)).toHaveLength(31);
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
@@ -61,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();
@@ -74,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" });
@@ -120,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");
@@ -131,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);
@@ -147,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);
}
@@ -169,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];
@@ -184,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");
@@ -198,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");
@@ -214,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");
@@ -226,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");
@@ -240,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");
@@ -253,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");
@@ -270,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");
@@ -286,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");
@@ -302,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");
@@ -315,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);
@@ -326,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().map((e) => e.hash)).toContain(metaHash as string);
expect(store.cas.listMeta().map((e) => e.hash)).toContain(
metaHash as string,
);
});
test("listSchemas contains meta-schema and all built-in schemas", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const schemas = store.listSchemas().map((e) => e.hash);
const aliases = bootstrap(store);
const schemas = store.cas.listSchemas().map((e) => e.hash);
for (const [, hash] of Object.entries(aliases)) {
expect(schemas).toContain(hash);
+78 -34
View File
@@ -3,7 +3,6 @@ import {
isBootstrapCapableStore,
} from "./bootstrap-capable.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
const JSON_SCHEMA_TYPES = [
"string",
@@ -132,6 +131,18 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
type: { type: "string", format: "ocas_ref" },
payload: {},
timestamp: { type: "number" },
tags: {
type: "array",
items: {
type: "object",
properties: {
key: { type: "string" },
value: { type: ["string", "null"] },
target: { type: "string", format: "ocas_ref" },
created: { type: "number" },
},
},
},
},
title: "ocas get result",
},
@@ -225,7 +236,21 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
"@ocas/output/var-get",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
properties: {
...VARIABLE_PROPERTIES,
valueTags: {
type: "array",
items: {
type: "object",
properties: {
key: { type: "string" },
value: { type: ["string", "null"] },
target: { type: "string", format: "ocas_ref" },
created: { type: "number" },
},
},
},
},
title: "ocas var get result",
},
],
@@ -237,14 +262,6 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
title: "ocas var delete result",
},
],
[
"@ocas/output/var-tag",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ocas var tag result",
},
],
[
"@ocas/output/var-list",
{
@@ -268,6 +285,38 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
title: "ocas var history result",
},
],
[
"@ocas/output/tag",
{
type: "array",
items: {
type: "object",
properties: {
key: { type: "string" },
value: { type: ["string", "null"] },
target: { type: "string", format: "ocas_ref" },
created: { type: "number" },
},
},
title: "ocas tag result",
},
],
[
"@ocas/output/untag",
{
type: "array",
items: {
type: "object",
properties: {
key: { type: "string" },
value: { type: ["string", "null"] },
target: { type: "string", format: "ocas_ref" },
created: { type: "number" },
},
},
title: "ocas untag result",
},
],
[
"@ocas/output/template-set",
{
@@ -326,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> = {
@@ -364,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;
+68
View File
@@ -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
View File
@@ -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 "bun:test";
import { bootstrap } from "./bootstrap.js";
import { gc } from "./gc.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import { VariableStore } from "./variable-store.js";
const tmpDbPath = () =>
join(
tmpdir(),
`test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
);
describe("GC - Variable Model Refactoring", () => {
let store: Store;
let dbPath: string;
afterEach(() => {
try {
unlinkSync(dbPath);
} catch {
// Ignore cleanup errors
}
});
test("GC preserves variable-referenced nodes", async () => {
store = createMemoryStore();
await bootstrap(store);
const store = createMemoryStore();
bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
const schemaHash = putSchema(store, schema);
const hashRef = await store.put(schemaHash, { name: "referenced" });
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
const hashRef = store.cas.put(schemaHash, { name: "referenced" });
const hashOrphan = store.cas.put(schemaHash, { name: "orphan" });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
store.var.set("@test/config", hashRef);
varStore.set("@test/config", hashRef);
const stats = gc(store);
const stats = gc(store, varStore);
expect(store.has(hashRef)).toBe(true);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(1);
expect(store.cas.has(hashRef)).toBe(true);
expect(store.cas.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBeGreaterThanOrEqual(1);
expect(stats.collected).toBeGreaterThanOrEqual(1);
varStore.close();
});
test("GC preserves nodes from variables with same name, different schemas", async () => {
store = createMemoryStore();
await bootstrap(store);
const store = createMemoryStore();
bootstrap(store);
const schemaA = { type: "object", properties: { x: { type: "number" } } };
const schemaB = { type: "object", properties: { y: { type: "string" } } };
const schemaAHash = await putSchema(store, schemaA);
const schemaBHash = await putSchema(store, schemaB);
const schemaAHash = putSchema(store, schemaA);
const schemaBHash = putSchema(store, schemaB);
const hashA = await store.put(schemaAHash, { x: 42 });
const hashB = await store.put(schemaBHash, { y: "hello" });
const hashOrphan = await store.put(schemaAHash, { x: 99 });
const hashA = store.cas.put(schemaAHash, { x: 42 });
const hashB = store.cas.put(schemaBHash, { y: "hello" });
const hashOrphan = store.cas.put(schemaAHash, { x: 99 });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
store.var.set("@test/config", hashA);
store.var.set("@test/config", hashB);
varStore.set("@test/config", hashA);
varStore.set("@test/config", hashB);
gc(store);
const stats = gc(store, varStore);
expect(store.has(hashA)).toBe(true);
expect(store.has(hashB)).toBe(true);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(2);
varStore.close();
expect(store.cas.has(hashA)).toBe(true);
expect(store.cas.has(hashB)).toBe(true);
expect(store.cas.has(hashOrphan)).toBe(false);
});
test("GC removes nodes after variable deletion", async () => {
store = createMemoryStore();
await bootstrap(store);
const store = createMemoryStore();
bootstrap(store);
const schema = { type: "object", properties: { name: { type: "string" } } };
const schemaHash = await putSchema(store, schema);
const schemaHash = putSchema(store, schema);
const hashRef = await store.put(schemaHash, { name: "referenced" });
const hashRef = store.cas.put(schemaHash, { name: "referenced" });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
store.var.set("@test/config", hashRef);
store.var.remove("@test/config", schemaHash);
varStore.set("@test/config", hashRef);
varStore.remove("@test/config", schemaHash);
gc(store);
const stats = gc(store, varStore);
expect(store.has(hashRef)).toBe(false);
expect(stats.scanned).toBe(0);
varStore.close();
expect(store.cas.has(hashRef)).toBe(false);
});
test("GC is global across all variables", async () => {
store = createMemoryStore();
await bootstrap(store);
const store = createMemoryStore();
bootstrap(store);
const schemaA = { type: "object", properties: { x: { type: "number" } } };
const schemaB = { type: "object", properties: { y: { type: "string" } } };
const schemaAHash = await putSchema(store, schemaA);
const schemaBHash = await putSchema(store, schemaB);
const schemaAHash = putSchema(store, schemaA);
const schemaBHash = putSchema(store, schemaB);
const hash1 = await store.put(schemaAHash, { x: 1 });
const hash2 = await store.put(schemaAHash, { x: 2 });
const hash3 = await store.put(schemaBHash, { y: "a" });
const hashOrphan = await store.put(schemaAHash, { x: 999 });
const hash1 = store.cas.put(schemaAHash, { x: 1 });
const hash2 = store.cas.put(schemaAHash, { x: 2 });
const hash3 = store.cas.put(schemaBHash, { y: "a" });
const hashOrphan = store.cas.put(schemaAHash, { x: 999 });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
store.var.set("@test/uwf.thread", hash1);
store.var.set("@test/uwf.workflow", hash2);
store.var.set("@test/app.config", hash3);
varStore.set("@test/uwf.thread", hash1);
varStore.set("@test/uwf.workflow", hash2);
varStore.set("@test/app.config", hash3);
gc(store);
const stats = gc(store, varStore);
expect(store.has(hash1)).toBe(true);
expect(store.has(hash2)).toBe(true);
expect(store.has(hash3)).toBe(true);
expect(store.has(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(3);
varStore.close();
expect(store.cas.has(hash1)).toBe(true);
expect(store.cas.has(hash2)).toBe(true);
expect(store.cas.has(hash3)).toBe(true);
expect(store.cas.has(hashOrphan)).toBe(false);
});
test("GC integration with refactored variable store", async () => {
store = createMemoryStore();
await bootstrap(store);
const store = createMemoryStore();
bootstrap(store);
const schemaA = { type: "object", properties: { x: { type: "number" } } };
const schemaB = { type: "object", properties: { y: { type: "string" } } };
const schemaAHash = await putSchema(store, schemaA);
const schemaBHash = await putSchema(store, schemaB);
const schemaAHash = putSchema(store, schemaA);
const schemaBHash = putSchema(store, schemaB);
const hashA1 = await store.put(schemaAHash, { x: 1 });
const hashA2 = await store.put(schemaAHash, { x: 2 });
const hashB = await store.put(schemaBHash, { y: "hello" });
const hashOrphan1 = await store.put(schemaAHash, { x: 999 });
const hashOrphan2 = await store.put(schemaBHash, { y: "orphan" });
const hashA1 = store.cas.put(schemaAHash, { x: 1 });
const hashA2 = store.cas.put(schemaAHash, { x: 2 });
const hashB = store.cas.put(schemaBHash, { y: "hello" });
store.cas.put(schemaAHash, { x: 999 });
store.cas.put(schemaBHash, { y: "orphan" });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
store.var.set("@test/var1", hashA1);
store.var.set("@test/var2", hashA2);
store.var.set("@test/var3", hashB);
// Create variables
varStore.set("@test/var1", hashA1);
varStore.set("@test/var2", hashA2);
varStore.set("@test/var3", hashB);
gc(store);
expect(store.cas.has(hashA1)).toBe(true);
expect(store.cas.has(hashA2)).toBe(true);
expect(store.cas.has(hashB)).toBe(true);
// First GC: orphans removed
let stats = gc(store, varStore);
expect(store.has(hashA1)).toBe(true);
expect(store.has(hashA2)).toBe(true);
expect(store.has(hashB)).toBe(true);
expect(store.has(hashOrphan1)).toBe(false);
expect(store.has(hashOrphan2)).toBe(false);
expect(stats.scanned).toBe(3);
store.var.remove("@test/var2", schemaAHash);
// Delete one variable
varStore.remove("@test/var2", schemaAHash);
// Second GC: hashA2 removed
stats = gc(store, varStore);
expect(store.has(hashA1)).toBe(true);
expect(store.has(hashA2)).toBe(false);
expect(store.has(hashB)).toBe(true);
expect(stats.scanned).toBe(2);
varStore.close();
gc(store);
expect(store.cas.has(hashA1)).toBe(true);
expect(store.cas.has(hashA2)).toBe(false);
expect(store.cas.has(hashB)).toBe(true);
});
});
+7 -8
View File
@@ -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,10 +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 {
export function gc(store: Store): GcStats {
// Get all variables (no filters → global). Omit `limit` so the full
// variable set is returned for use as gc roots.
const variables = varStore.list();
const variables = store.var.list();
const scanned = variables.length;
// Collect unique root hashes from all variables
@@ -44,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);
}
@@ -55,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;
@@ -66,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);
}
@@ -81,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++;
}
}
+32
View File
@@ -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.
+61 -56
View File
@@ -4,7 +4,7 @@ import { bootstrap } from "./bootstrap.js";
import { cborEncode } from "./cbor.js";
import { computeHash, computeSelfHash } from "./hash.js";
import { createMemoryStore } from "./store.js";
import type { CasNode, Store } from "./types.js";
import type { CasNode, CasStore } from "./types.js";
import { verify } from "./verify.js";
// ──────────────────────────────────────────────────────────────────────────────
@@ -68,17 +68,17 @@ describe("computeHash", () => {
});
// ──────────────────────────────────────────────────────────────────────────────
// Step 3: store.put() and store.get()
// Step 3: store.cas.put() and store.cas.get()
// ──────────────────────────────────────────────────────────────────────────────
describe("createMemoryStore – put and get", () => {
test("put returns a hash and get retrieves the node", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "my-type" });
const hash = await store.put(typeHash, { greeting: "hello" });
const hash = await store.cas.put(typeHash, { greeting: "hello" });
expect(hash).toHaveLength(13);
const node = store.get(hash);
const node = store.cas.get(hash);
expect(node).not.toBeNull();
expect(node?.type).toBe(typeHash);
expect(node?.payload).toEqual({ greeting: "hello" });
@@ -87,26 +87,26 @@ describe("createMemoryStore – put and get", () => {
test("get returns null for unknown hash", () => {
const store = createMemoryStore();
expect(store.get("0000000000000")).toBeNull();
expect(store.cas.get("0000000000000")).toBeNull();
});
test("put is idempotent: same type+payload → same hash, no duplicate", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "my-type" });
const h1 = await store.put(typeHash, { n: 42 });
const h2 = await store.put(typeHash, { n: 42 });
const h1 = await store.cas.put(typeHash, { n: 42 });
const h2 = await store.cas.put(typeHash, { n: 42 });
expect(h1).toBe(h2);
expect(store.listByType(typeHash)).toHaveLength(1);
expect(store.cas.listByType(typeHash)).toHaveLength(1);
});
test("put does not create self-referencing nodes", async () => {
const store = createMemoryStore();
const payload = { name: "type-descriptor" };
const typeHash = await computeSelfHash(payload);
const hash = await store.put(typeHash, payload);
const hash = await store.cas.put(typeHash, payload);
const node = store.get(hash);
const node = store.cas.get(hash);
expect(node?.type).toBe(typeHash);
expect(node?.type).not.toBe(hash);
});
@@ -115,19 +115,19 @@ describe("createMemoryStore – put and get", () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "my-type" });
const h1 = await store.put(typeHash, { v: 1 });
const ts1 = store.get(h1)?.timestamp;
const h1 = await store.cas.put(typeHash, { v: 1 });
const ts1 = store.cas.get(h1)?.timestamp;
await new Promise((r) => setTimeout(r, 5));
await store.put(typeHash, { v: 1 });
const ts2 = store.get(h1)?.timestamp;
await store.cas.put(typeHash, { v: 1 });
const ts2 = store.cas.get(h1)?.timestamp;
expect(ts1).toBe(ts2);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Step 4: store.has()
// Step 4: store.cas.has()
// ──────────────────────────────────────────────────────────────────────────────
describe("createMemoryStore – has", () => {
test("has returns false before put, true after", async () => {
@@ -135,20 +135,20 @@ describe("createMemoryStore – has", () => {
const typeHash = await computeSelfHash({ name: "t" });
const hash = await computeHash(typeHash, { x: 1 });
expect(store.has(hash)).toBe(false);
await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
expect(store.cas.has(hash)).toBe(false);
await store.cas.put(typeHash, { x: 1 });
expect(store.cas.has(hash)).toBe(true);
});
test("listByType returns all stored hashes for a type", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "t" });
const h1 = await store.put(typeHash, { a: 1 });
const h2 = await store.put(typeHash, { a: 2 });
const h3 = await store.put(typeHash, { a: 3 });
const h1 = await store.cas.put(typeHash, { a: 1 });
const h2 = await store.cas.put(typeHash, { a: 2 });
const h3 = await store.cas.put(typeHash, { a: 3 });
const all = store.listByType(typeHash).map((e) => e.hash);
const all = store.cas.listByType(typeHash).map((e) => e.hash);
expect(all).toHaveLength(3);
expect(all).toContain(h1);
expect(all).toContain(h2);
@@ -157,17 +157,17 @@ describe("createMemoryStore – has", () => {
test("listByType returns empty array on fresh store", () => {
const store = createMemoryStore();
expect(store.listByType("0000000000000")).toEqual([]);
expect(store.cas.listByType("0000000000000")).toEqual([]);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Step 4b: store.listByType()
// Step 4b: store.cas.listByType()
// ──────────────────────────────────────────────────────────────────────────────
describe("createMemoryStore – listByType", () => {
test("returns empty array for unknown type", () => {
const store = createMemoryStore();
expect(store.listByType("0000000000000")).toEqual([]);
expect(store.cas.listByType("0000000000000")).toEqual([]);
});
test("returns all hashes for the given type", async () => {
@@ -175,11 +175,11 @@ describe("createMemoryStore – listByType", () => {
const typeHash = await computeSelfHash({ name: "t" });
const otherType = await computeSelfHash({ name: "other" });
const h1 = await store.put(typeHash, { a: 1 });
const h2 = await store.put(typeHash, { a: 2 });
await store.put(otherType, { b: 1 });
const h1 = await store.cas.put(typeHash, { a: 1 });
const h2 = await store.cas.put(typeHash, { a: 2 });
await store.cas.put(otherType, { b: 1 });
const byType = store.listByType(typeHash).map((e) => e.hash);
const byType = store.cas.listByType(typeHash).map((e) => e.hash);
expect(byType).toHaveLength(2);
expect(byType).toContain(h1);
expect(byType).toContain(h2);
@@ -189,19 +189,19 @@ describe("createMemoryStore – listByType", () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "t" });
const h1 = await store.put(typeHash, { n: 1 });
await store.put(typeHash, { n: 1 });
const h1 = await store.cas.put(typeHash, { n: 1 });
await store.cas.put(typeHash, { n: 1 });
expect(store.listByType(typeHash).map((e) => e.hash)).toEqual([h1]);
expect(store.cas.listByType(typeHash).map((e) => e.hash)).toEqual([h1]);
});
test("bootstrap node is listed under its self type", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const builtinSchemas = bootstrap(store);
const hash = builtinSchemas["@ocas/schema"] ?? "";
// All built-in schemas should be typed by the meta-schema
const allTypedByMeta = store.listByType(hash).map((e) => e.hash);
const allTypedByMeta = store.cas.listByType(hash).map((e) => e.hash);
expect(allTypedByMeta).toContain(hash); // meta-schema itself
expect(allTypedByMeta).toContain(builtinSchemas["@ocas/string"] ?? "");
expect(allTypedByMeta).toContain(builtinSchemas["@ocas/number"] ?? "");
@@ -218,8 +218,8 @@ describe("verify", () => {
test("returns true for a correctly stored node", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "my-type" });
const hash = await store.put(typeHash, { data: 123 });
const node = store.get(hash) as CasNode;
const hash = await store.cas.put(typeHash, { data: 123 });
const node = store.cas.get(hash) as CasNode;
expect(await verify(hash, node)).toBe(true);
});
@@ -227,7 +227,7 @@ describe("verify", () => {
test("returns false when payload is tampered", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "my-type" });
const hash = await store.put(typeHash, { data: 123 });
const hash = await store.cas.put(typeHash, { data: 123 });
const tampered: CasNode = {
type: typeHash,
@@ -240,8 +240,8 @@ describe("verify", () => {
test("returns false when type is tampered", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "my-type" });
const hash = await store.put(typeHash, { data: 123 });
const node = store.get(hash) as CasNode;
const hash = await store.cas.put(typeHash, { data: 123 });
const node = store.cas.get(hash) as CasNode;
const tampered: CasNode = { ...node, type: "AAAAAAAAAAAAA" };
expect(await verify(hash, tampered)).toBe(false);
@@ -253,20 +253,25 @@ describe("verify", () => {
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap", () => {
test("throws when store lacks internal bootstrap path", async () => {
const store: Store = {
put: async () => "0000000000000",
const cas = {
put: () => "0000000000000",
get: () => null,
has: () => false,
listByType: () => [],
};
await expect(bootstrap(store)).rejects.toThrow(
} as unknown as CasStore;
const fakeStore = {
cas,
var: { set: () => null } as never,
tag: {} as never,
} as never;
expect(() => bootstrap(fakeStore)).toThrow(
"Store does not support bootstrap",
);
});
test("returns a map with 30 built-in schema aliases", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const builtinSchemas = bootstrap(store);
expect(builtinSchemas).toHaveProperty("@ocas/schema");
expect(builtinSchemas).toHaveProperty("@ocas/string");
@@ -284,44 +289,44 @@ describe("bootstrap", () => {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(Object.keys(builtinSchemas)).toHaveLength(30);
expect(Object.keys(builtinSchemas)).toHaveLength(31);
});
test("meta-schema node is stored and retrievable", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const builtinSchemas = bootstrap(store);
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
expect(store.has(metaHash)).toBe(true);
const node = store.get(metaHash);
expect(store.cas.has(metaHash)).toBe(true);
const node = store.cas.get(metaHash);
expect(node).not.toBeNull();
});
test("meta-schema node is self-referencing: type === hash", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const builtinSchemas = bootstrap(store);
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
const node = store.get(metaHash) as CasNode;
const node = store.cas.get(metaHash) as CasNode;
expect(node.type).toBe(metaHash);
});
test("bootstrap node passes verify()", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
const builtinSchemas = bootstrap(store);
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
const node = store.get(metaHash) as CasNode;
const node = store.cas.get(metaHash) as CasNode;
expect(await verify(metaHash, node)).toBe(true);
});
test("bootstrap is idempotent: same hashes on repeated calls", async () => {
const store = createMemoryStore();
const h1 = await bootstrap(store);
const h2 = await bootstrap(store);
const h1 = bootstrap(store);
const h2 = bootstrap(store);
expect(h1).toEqual(h2);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 21 outputs)
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 22 outputs)
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
});
});
+46 -19
View File
@@ -2,8 +2,23 @@ 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";
@@ -22,26 +37,38 @@ export {
validate,
walk,
} from "./schema.js";
export { createMemoryStore } from "./store.js";
export {
type CasNode,
type Hash,
type ListEntry,
type ListOptions,
type ListSort,
type Store,
createMemoryStore,
createMemoryTagStoreImpl,
createMemoryVarStoreFor,
} from "./store.js";
export type {
CasNode,
CasStore,
Hash,
HistoryEntry,
ListEntry,
ListOptions,
ListSort,
Store,
Tag,
TagOp,
TagStore,
VarListOptions,
VarSetOptions,
VarStore,
} from "./types.js";
export type { Variable } from "./variable.js";
export { validateName } from "./validation.js";
export {
CasNodeNotFoundError,
createVariableStore,
InvalidTagFormatError,
InvalidVariableNameError,
MAX_HISTORY,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
addNameIndex,
checkTagLabelConflict,
cloneVarRecord,
extractSchema,
pushHistory,
removeNameIndex,
type VarRecord,
varKey,
} from "./var-store-helpers.js";
export type { Variable } from "./variable.js";
export { verify } from "./verify.js";
export { wrapEnvelope } from "./wrap-envelope.js";
File diff suppressed because it is too large Load Diff
+11 -28
View File
@@ -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";
+43 -43
View File
@@ -12,7 +12,7 @@ async function putN(
): Promise<string[]> {
const hashes: string[] = [];
for (let i = 0; i < n; i++) {
hashes.push(await store.put(type, { i }));
hashes.push(store.cas.put(type, { i }));
if (delayMs > 0 && i < n - 1) {
await new Promise((r) => setTimeout(r, delayMs));
}
@@ -23,10 +23,10 @@ async function putN(
describe("listByType - pagination + sort + timestamps", () => {
test("A1. returns objects with hash/created/updated", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
await putN(store, m, 3, 0);
const list = store.listByType(m);
const list = store.cas.listByType(m);
for (const e of list) {
expect(e.hash).toMatch(HASH_RE);
expect(typeof e.created).toBe("number");
@@ -37,10 +37,10 @@ describe("listByType - pagination + sort + timestamps", () => {
test("A2. default sort is created ASC", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
await putN(store, m, 4);
const list = store.listByType(m);
const list = store.cas.listByType(m);
for (let i = 1; i < list.length; i++) {
expect((list[i] as { created: number }).created).toBeGreaterThanOrEqual(
(list[i - 1] as { created: number }).created,
@@ -50,10 +50,10 @@ describe("listByType - pagination + sort + timestamps", () => {
test("A3. desc:true reverses order", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
await putN(store, m, 4);
const list = store.listByType(m, { desc: true });
const list = store.cas.listByType(m, { desc: true });
for (let i = 1; i < list.length; i++) {
expect((list[i] as { created: number }).created).toBeLessThanOrEqual(
(list[i - 1] as { created: number }).created,
@@ -63,61 +63,61 @@ describe("listByType - pagination + sort + timestamps", () => {
test("A4. sort: 'updated' is equivalent to 'created' for CAS nodes", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
await putN(store, m, 4);
const a = store.listByType(m, { sort: "created" });
const b = store.listByType(m, { sort: "updated" });
const a = store.cas.listByType(m, { sort: "created" });
const b = store.cas.listByType(m, { sort: "updated" });
expect(a).toEqual(b);
});
test("A5. limit truncates", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
await putN(store, m, 5, 0);
expect(store.listByType(m, { limit: 2 })).toHaveLength(2);
expect(store.cas.listByType(m, { limit: 2 })).toHaveLength(2);
});
test("A6. offset skips", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
await putN(store, m, 5);
const all = store.listByType(m);
const skip = store.listByType(m, { offset: 2, limit: 10 });
const all = store.cas.listByType(m);
const skip = store.cas.listByType(m, { offset: 2, limit: 10 });
expect(skip).toHaveLength(all.length - 2);
expect(skip[0]).toEqual(all[2] as (typeof all)[number]);
});
test("A7. limit:0 returns empty array", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
await putN(store, m, 3, 0);
expect(store.listByType(m, { limit: 0 })).toEqual([]);
expect(store.cas.listByType(m, { limit: 0 })).toEqual([]);
});
test("A8. offset past end returns empty array", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
await putN(store, m, 3, 0);
expect(store.listByType(m, { offset: 100 })).toEqual([]);
expect(store.cas.listByType(m, { offset: 100 })).toEqual([]);
});
test("A9. core has no default limit (returns all)", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
await putN(store, m, 150, 0);
// No CLI-layer cap; with 150 nodes of type m (plus m itself which is
// self-typed), the full set is returned.
expect(store.listByType(m)).toHaveLength(151);
expect(store.cas.listByType(m)).toHaveLength(151);
});
test("A10. desc + offset + limit combined", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
await putN(store, m, 5, 15);
const all = store.listByType(m);
const got = store.listByType(m, { desc: true, offset: 1, limit: 2 });
const all = store.cas.listByType(m);
const got = store.cas.listByType(m, { desc: true, offset: 1, limit: 2 });
expect(got).toHaveLength(2);
// desc order is reverse of `all`; offset 1 + limit 2 → all[n-2], all[n-3]
const n = all.length;
@@ -129,8 +129,8 @@ describe("listByType - pagination + sort + timestamps", () => {
describe("listMeta / listSchemas - pagination", () => {
test("B1. listMeta returns {hash,created,updated}", async () => {
const store = createMemoryStore();
const h = await store[BOOTSTRAP_STORE]({ type: "object" });
const list = store.listMeta();
const h = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
const list = store.cas.listMeta();
expect(list).toHaveLength(1);
const e = list[0] as { hash: string; created: number; updated: number };
expect(e.hash).toBe(h);
@@ -141,53 +141,53 @@ describe("listMeta / listSchemas - pagination", () => {
test("B2. listMeta has no default limit (returns all)", async () => {
const store = createMemoryStore();
for (let i = 0; i < 150; i++) {
await store[BOOTSTRAP_STORE]({ type: "object", i });
await store.cas[BOOTSTRAP_STORE]({ type: "object", i });
}
expect(store.listMeta()).toHaveLength(150);
expect(store.cas.listMeta()).toHaveLength(150);
});
test("B3. listMeta limit/offset/desc", async () => {
const store = createMemoryStore();
for (let i = 0; i < 5; i++) {
await store[BOOTSTRAP_STORE]({ type: "object", i });
await store.cas[BOOTSTRAP_STORE]({ type: "object", i });
await new Promise((r) => setTimeout(r, 2));
}
expect(store.listMeta({ limit: 2 })).toHaveLength(2);
const all = store.listMeta();
const desc = store.listMeta({ desc: true });
expect(store.cas.listMeta({ limit: 2 })).toHaveLength(2);
const all = store.cas.listMeta();
const desc = store.cas.listMeta({ desc: true });
expect(desc[0]).toEqual(all[all.length - 1] as (typeof all)[number]);
});
test("B4. listSchemas returns objects, supports limit", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
await store.put(m, { type: "string" });
await store.put(m, { type: "number" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
store.cas.put(m, { type: "string" });
store.cas.put(m, { type: "number" });
const list = store.listSchemas();
const list = store.cas.listSchemas();
for (const e of list) {
expect(e.hash).toMatch(HASH_RE);
expect(typeof e.created).toBe("number");
}
expect(store.listSchemas({ limit: 1 })).toHaveLength(1);
expect(store.cas.listSchemas({ limit: 1 })).toHaveLength(1);
});
});
describe("Determinism / edge cases", () => {
test("I1. same-ms timestamps yield deterministic ordering across calls", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
// No delay → likely same millisecond
await putN(store, m, 5, 0);
const a = store.listByType(m);
const b = store.listByType(m);
const a = store.cas.listByType(m);
const b = store.cas.listByType(m);
expect(b).toEqual(a);
});
test("I2. empty store returns []", () => {
const store = createMemoryStore();
expect(store.listByType("0000000000000")).toEqual([]);
expect(store.listMeta()).toEqual([]);
expect(store.listSchemas()).toEqual([]);
expect(store.cas.listByType("0000000000000")).toEqual([]);
expect(store.cas.listMeta()).toEqual([]);
expect(store.cas.listSchemas()).toEqual([]);
});
});
+30 -42
View File
@@ -1,49 +1,37 @@
import type { BootstrapCapableStore } from "./bootstrap-capable.js";
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
import { createMemoryStore } from "./store.js";
import type { CasNode, Hash, ListEntry, ListOptions } from "./types.js";
import type { CasStore, Store, TagStore, VarStore } from "./types.js";
/** In-memory store wrapper used by schema validation tests. */
export class MemStore implements BootstrapCapableStore {
readonly #inner: BootstrapCapableStore;
/**
* In-memory `Store` used by schema validation tests. It exposes the
* `cas`, `var`, and `tag` sub-stores of an `Store` plus a few legacy
* pass-through helpers (`get`, `put`, `has`, ) that some older tests still
* use directly.
*/
export class MemStore implements Store {
readonly cas: CasStore;
readonly var: VarStore;
readonly tag: TagStore;
constructor() {
this.#inner = createMemoryStore();
const store = createMemoryStore();
this.cas = store.cas;
this.var = store.var;
this.tag = store.tag;
}
put(typeHash: Hash, payload: unknown): Promise<Hash> {
return this.#inner.put(typeHash, payload);
}
get(hash: Hash): CasNode | null {
return this.#inner.get(hash);
}
has(hash: Hash): boolean {
return this.#inner.has(hash);
}
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
return this.#inner.listByType(typeHash, options);
}
listAll(): Hash[] {
return this.#inner.listAll();
}
listMeta(options?: ListOptions): ListEntry[] {
return this.#inner.listMeta(options);
}
listSchemas(options?: ListOptions): ListEntry[] {
return this.#inner.listSchemas(options);
}
delete(hash: Hash): void {
this.#inner.delete(hash);
}
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash> {
return this.#inner[BOOTSTRAP_STORE](payload);
}
// Legacy convenience pass-throughs ----------------------------------------
get = (hash: Parameters<CasStore["get"]>[0]): ReturnType<CasStore["get"]> =>
this.cas.get(hash);
has = (hash: Parameters<CasStore["has"]>[0]): ReturnType<CasStore["has"]> =>
this.cas.has(hash);
put = (
typeHash: Parameters<CasStore["put"]>[0],
payload: unknown,
): ReturnType<CasStore["put"]> => this.cas.put(typeHash, payload);
listByType: CasStore["listByType"] = (typeHash, options) =>
this.cas.listByType(typeHash, options);
listAll: CasStore["listAll"] = () => this.cas.listAll();
listMeta: CasStore["listMeta"] = (options) => this.cas.listMeta(options);
listSchemas: CasStore["listSchemas"] = (options) =>
this.cas.listSchemas(options);
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
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.dir;
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);
}
});
});
+26 -45
View File
@@ -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 "bun:test";
import { bootstrap } from "./bootstrap.js";
import { registerOutputTemplates } from "./output-templates.js";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
import { createVariableStore } from "./variable-store.js";
const OUTPUT_ALIASES = [
"@ocas/output/put",
@@ -21,9 +15,10 @@ const OUTPUT_ALIASES = [
"@ocas/output/var-set",
"@ocas/output/var-get",
"@ocas/output/var-delete",
"@ocas/output/var-tag",
"@ocas/output/var-list",
"@ocas/output/var-history",
"@ocas/output/tag",
"@ocas/output/untag",
"@ocas/output/template-set",
"@ocas/output/template-get",
"@ocas/output/template-list",
@@ -32,24 +27,13 @@ const OUTPUT_ALIASES = [
] as const;
describe("registerOutputTemplates", () => {
let store: Store;
let varStore: VariableStore;
let tempDir: string;
afterEach(async () => {
varStore.close();
await rm(tempDir, { recursive: true });
});
test("registers a template for every @ocas/output/* schema", async () => {
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
store = createMemoryStore();
await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const store = createMemoryStore();
bootstrap(store);
const registered = await registerOutputTemplates(store, varStore);
const registered = await registerOutputTemplates(store);
expect(Object.keys(registered)).toHaveLength(19);
expect(Object.keys(registered)).toHaveLength(20);
for (const alias of OUTPUT_ALIASES) {
expect(registered).toHaveProperty(alias);
@@ -57,25 +41,23 @@ describe("registerOutputTemplates", () => {
});
test("each template is retrievable via @ocas/template/text/<hash>", async () => {
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
store = createMemoryStore();
const aliases = await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const store = createMemoryStore();
const aliases = bootstrap(store);
await registerOutputTemplates(store, varStore);
await registerOutputTemplates(store);
const stringHash = aliases["@ocas/string"];
if (!stringHash) throw new Error("@string not found");
if (!stringHash) throw new Error("@ocas/string not found");
for (const alias of OUTPUT_ALIASES) {
const schemaHash = aliases[alias];
if (!schemaHash) throw new Error(`${alias} not found`);
const varName = `@ocas/template/text/${schemaHash}`;
const variable = varStore.get(varName, stringHash);
const variable = store.var.get(varName, stringHash);
if (variable === null) throw new Error(`Variable ${varName} not found`);
const templateNode = store.get(variable.value);
const templateNode = store.cas.get(variable.value);
if (templateNode === null)
throw new Error(`Template node ${variable.value} not found`);
expect(typeof templateNode.payload).toBe("string");
@@ -83,35 +65,34 @@ describe("registerOutputTemplates", () => {
});
test("is idempotent — safe to call multiple times", async () => {
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
store = createMemoryStore();
await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const store = createMemoryStore();
bootstrap(store);
const first = await registerOutputTemplates(store, varStore);
const second = await registerOutputTemplates(store, varStore);
const first = await registerOutputTemplates(store);
const second = await registerOutputTemplates(store);
expect(first).toEqual(second);
});
test("@ocas/output/put template contains payload reference", async () => {
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
store = createMemoryStore();
const aliases = await bootstrap(store);
varStore = createVariableStore(join(tempDir, "vars.db"), store);
const store = createMemoryStore();
const aliases = bootstrap(store);
await registerOutputTemplates(store, varStore);
await registerOutputTemplates(store);
const putHash = aliases["@ocas/output/put"];
if (!putHash) throw new Error("@ocas/output/put not found");
const stringHash = aliases["@ocas/string"];
if (!stringHash) throw new Error("@string not found");
if (!stringHash) throw new Error("@ocas/string not found");
const variable = varStore.get(`@ocas/template/text/${putHash}`, stringHash);
const variable = store.var.get(
`@ocas/template/text/${putHash}`,
stringHash,
);
if (variable === null)
throw new Error("@ocas/output/put template variable not found");
const templateNode = store.get(variable.value);
const templateNode = store.cas.get(variable.value);
if (templateNode === null) throw new Error("Template node not found");
expect(templateNode.payload).toBe("{{ payload }}");
});
+13 -11
View File
@@ -1,6 +1,5 @@
import { bootstrap } from "./bootstrap.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
const DEFAULT_TEMPLATES: ReadonlyArray<
readonly [alias: string, template: string]
@@ -28,14 +27,18 @@ const DEFAULT_TEMPLATES: ReadonlyArray<
"@ocas/output/var-delete",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@ocas/output/var-tag",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@ocas/output/var-list",
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}",
],
[
"@ocas/output/tag",
"{% for t in payload %}{{ t.key }}{% if t.value %}:{{ t.value }}{% endif %}\n{% endfor %}",
],
[
"@ocas/output/untag",
"{% for t in payload %}{{ t.key }}{% if t.value %}:{{ t.value }}{% endif %}\n{% endfor %}",
],
[
"@ocas/output/var-history",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\n{% for v in payload.values %}{{ forloop.index0 }}: {{ v }}\n{% endfor %}",
@@ -58,19 +61,18 @@ const DEFAULT_TEMPLATES: ReadonlyArray<
/**
* Register default LiquidJS templates for all @ocas/output/* schemas.
* Each template is stored as a @string CAS node and bound to
* Each template is stored as a @ocas/string CAS node and bound to
* the variable `@ocas/template/text/<schema-hash>`.
*
* Idempotent: safe to call multiple times.
*/
export async function registerOutputTemplates(
store: Store,
varStore: VariableStore,
): Promise<Record<string, Hash>> {
const aliases = await bootstrap(store);
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"];
if (stringHash === undefined) {
throw new Error("@string schema not found in bootstrap result");
throw new Error("@ocas/string schema not found in bootstrap result");
}
const registered: Record<string, Hash> = {};
@@ -81,9 +83,9 @@ export async function registerOutputTemplates(
throw new Error(`Schema alias not found: ${alias}`);
}
const contentHash = await store.put(stringHash, template);
const contentHash = store.cas.put(stringHash, template);
const varName = `@ocas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
store.var.set(varName, contentHash);
registered[alias] = contentHash;
}
+142 -142
View File
@@ -1,17 +1,17 @@
import { describe, expect, test } from "bun:test";
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
View File
@@ -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}`;
+157 -165
View File
@@ -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);
});
+5 -8
View File
@@ -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);
+114 -48
View File
@@ -2,88 +2,154 @@ import { describe, expect, test } from "bun:test";
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
import { createMemoryStore } from "./store.js";
describe("createMemoryStore – meta and schema indexes", () => {
describe("A. createMemoryStore – shape", () => {
test("A1. returns an object with cas, var, tag sub-stores", () => {
const store = createMemoryStore();
expect(typeof store.cas).toBe("object");
expect(store.cas).not.toBeNull();
expect(typeof store.var).toBe("object");
expect(store.var).not.toBeNull();
expect(typeof store.tag).toBe("object");
expect(store.tag).not.toBeNull();
});
test("A2. cas/var/tag are independent objects", () => {
const store = createMemoryStore();
expect(store.cas).not.toBe(store.var as unknown as typeof store.cas);
expect(store.cas).not.toBe(store.tag as unknown as typeof store.cas);
expect(store.var).not.toBe(store.tag as unknown as typeof store.var);
});
});
describe("B. cas sub-store – meta and schema indexes", () => {
test("B1. listMeta and listSchemas are empty on a fresh store", () => {
const store = createMemoryStore();
expect(store.listMeta()).toEqual([]);
expect(store.listSchemas()).toEqual([]);
expect(store.cas.listMeta()).toEqual([]);
expect(store.cas.listSchemas()).toEqual([]);
});
test("B2. self-referencing put adds hash to metaSet and listSchemas", async () => {
test("B2. self-referencing put adds hash to metaSet and listSchemas", () => {
const store = createMemoryStore();
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
expect(store.listMeta().map((e) => e.hash)).toContain(hash);
expect(store.listSchemas().map((e) => e.hash)).toContain(hash);
const hash = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
expect(store.cas.listMeta().map((e) => e.hash)).toContain(hash);
expect(store.cas.listSchemas().map((e) => e.hash)).toContain(hash);
});
test("B3. regular put does not add hash to metaSet", async () => {
test("B3. regular put does not add hash to metaSet", () => {
const store = createMemoryStore();
const metaHash = await store[BOOTSTRAP_STORE]({ type: "object" });
const schemaHash = await store.put(metaHash, { type: "string" });
expect(store.listMeta().map((e) => e.hash)).not.toContain(schemaHash);
expect(store.listMeta().map((e) => e.hash)).toContain(metaHash);
const metaHash = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
const schemaHash = store.cas.put(metaHash, { type: "string" });
expect(store.cas.listMeta().map((e) => e.hash)).not.toContain(schemaHash);
expect(store.cas.listMeta().map((e) => e.hash)).toContain(metaHash);
});
test("B4. schema typed by meta-schema appears in listSchemas", async () => {
test("B4. schema typed by meta-schema appears in listSchemas", () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s = await store.put(m, { type: "string" });
const schemas = store.listSchemas().map((e) => e.hash);
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
const s = store.cas.put(m, { type: "string" });
const schemas = store.cas.listSchemas().map((e) => e.hash);
expect(schemas).toContain(m);
expect(schemas).toContain(s);
const meta = store.listMeta().map((e) => e.hash);
const meta = store.cas.listMeta().map((e) => e.hash);
expect(meta).toContain(m);
expect(meta).not.toContain(s);
});
test("B5. multiple meta-schemas (versioning)", async () => {
test("B5. multiple meta-schemas (versioning)", () => {
const store = createMemoryStore();
const m1 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v1" });
const m2 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v2" });
const s1 = await store.put(m1, { type: "string" });
const s2 = await store.put(m2, { type: "number" });
const meta = store.listMeta().map((e) => e.hash);
const m1 = store.cas[BOOTSTRAP_STORE]({
type: "object",
title: "v1",
}) as string;
const m2 = store.cas[BOOTSTRAP_STORE]({
type: "object",
title: "v2",
}) as string;
const s1 = store.cas.put(m1, { type: "string" });
const s2 = store.cas.put(m2, { type: "number" });
const meta = store.cas.listMeta().map((e) => e.hash);
expect(meta).toContain(m1);
expect(meta).toContain(m2);
expect(meta).toHaveLength(2);
const schemas = store.listSchemas().map((e) => e.hash);
const schemas = store.cas.listSchemas().map((e) => e.hash);
expect(schemas).toContain(m1);
expect(schemas).toContain(m2);
expect(schemas).toContain(s1);
expect(schemas).toContain(s2);
});
test("B6. idempotent self-referencing put does not duplicate", async () => {
test("B6. idempotent self-referencing put does not duplicate", () => {
const store = createMemoryStore();
const payload = { type: "object", title: "dup" };
const h1 = await store[BOOTSTRAP_STORE](payload);
const h2 = await store[BOOTSTRAP_STORE](payload);
const h1 = store.cas[BOOTSTRAP_STORE](payload) as string;
const h2 = store.cas[BOOTSTRAP_STORE](payload) as string;
expect(h1).toBe(h2);
const meta = store.listMeta().map((e) => e.hash);
const occurrences = meta.filter((h) => h === h1).length;
expect(occurrences).toBe(1);
const meta = store.cas.listMeta().map((e) => e.hash);
expect(meta.filter((h) => h === h1)).toHaveLength(1);
});
test("B7. delete removes hash from metaSet and listSchemas", async () => {
test("B7. delete removes hash from metaSet and listSchemas", () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s = await store.put(m, { type: "string" });
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
const s = store.cas.put(m, { type: "string" });
expect(store.cas.listMeta().map((e) => e.hash)).toContain(m);
expect(store.cas.listSchemas().map((e) => e.hash)).toContain(s);
store.cas.delete(m);
expect(store.cas.listMeta().map((e) => e.hash)).not.toContain(m);
expect(store.cas.listSchemas().map((e) => e.hash)).not.toContain(s);
expect(store.cas.listSchemas().map((e) => e.hash)).not.toContain(m);
});
expect(store.listMeta().map((e) => e.hash)).toContain(m);
expect(store.listSchemas().map((e) => e.hash)).toContain(s);
test("B8. cas.put returns Hash synchronously (not a Promise)", () => {
const store = createMemoryStore();
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
const result = store.cas.put(m, { type: "string" });
expect(typeof result).toBe("string");
expect(result).toHaveLength(13);
});
store.delete(m);
expect(store.listMeta().map((e) => e.hash)).not.toContain(m);
// schemas typed by deleted meta no longer surface
expect(store.listSchemas().map((e) => e.hash)).not.toContain(s);
expect(store.listSchemas().map((e) => e.hash)).not.toContain(m);
test("B9. has/get/delete reflect put state", () => {
const store = createMemoryStore();
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
const h = store.cas.put(m, { type: "string" });
expect(store.cas.has(h)).toBe(true);
expect(store.cas.get(h)?.payload).toEqual({ type: "string" });
expect(store.cas.has("0000000000000")).toBe(false);
expect(store.cas.get("0000000000000")).toBeNull();
expect(store.cas.delete(h)).toBe(true);
expect(store.cas.delete(h)).toBe(false);
expect(store.cas.has(h)).toBe(false);
});
});
describe("E. cross-store independence", () => {
test("E1. tagging a hash does not surface it in cas.listByType", () => {
const store = createMemoryStore();
const fakeTarget = "0000000000000";
store.tag.tag(fakeTarget, [{ op: "set", key: "env", value: "prod" }]);
expect(store.cas.listByType(fakeTarget)).toEqual([]);
});
test("E2. setting a variable does not mutate the CAS node", () => {
const store = createMemoryStore();
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
const s = store.cas.put(m, { type: "string" });
const value = store.cas.put(s, "hello");
const before = store.cas.get(value);
store.var.set("@app/x", value);
const after = store.cas.get(value);
expect(after).toEqual(before);
});
test("E3. cas.delete does not cascade-delete a variable referencing it", () => {
const store = createMemoryStore();
const m = store.cas[BOOTSTRAP_STORE]({ type: "object" }) as string;
const s = store.cas.put(m, { type: "string" });
const value = store.cas.put(s, "hello");
store.var.set("@app/x", value);
store.cas.delete(value);
const got = store.var.get("@app/x", s);
expect(got).not.toBeNull();
expect(got?.value).toBe(value);
});
});
+391 -21
View File
@@ -2,11 +2,53 @@ import {
BOOTSTRAP_STORE,
type BootstrapCapableStore,
} from "./bootstrap-capable.js";
import { computeHash, computeSelfHash } from "./hash.js";
import { SchemaMismatchError, VariableNotFoundError } from "./errors.js";
import { computeHashSync, computeSelfHashSync, initHasher } from "./hash.js";
import { applyListOptions, casListEntry } from "./list-utils.js";
import type { CasNode, Hash, ListEntry, ListOptions } from "./types.js";
import type {
CasNode,
CasStore,
Hash,
HistoryEntry,
ListEntry,
ListOptions,
Store,
Tag,
TagOp,
TagStore,
VarListOptions,
VarSetOptions,
VarStore,
} from "./types.js";
import { validateName } from "./validation.js";
import {
addNameIndex,
checkTagLabelConflict,
cloneVarRecord,
extractSchema,
pushHistory,
removeNameIndex,
type VarRecord,
varKey,
} from "./var-store-helpers.js";
import type { Variable } from "./variable.js";
export function createMemoryStore(): BootstrapCapableStore {
// Initialise the xxhash WASM instance once at module load. This allows the
// CAS sub-store's `put` method to be synchronous (per the new CasStore type).
await initHasher();
/**
* The cas sub-store of an in-memory `Store` also satisfies the legacy
* `BootstrapCapableStore` interface so that helpers that have not yet been
* refactored (e.g. bootstrap, gc, render) continue to work against
* `store.cas`.
*/
export type MemoryCasStore = BootstrapCapableStore & {
put(typeHash: Hash, payload: unknown): Hash;
delete(hash: Hash): boolean;
};
function createCasStore(): MemoryCasStore {
const data = new Map<Hash, CasNode>();
const byType = new Map<Hash, Set<Hash>>();
const metaSet = new Set<Hash>();
@@ -20,8 +62,8 @@ export function createMemoryStore(): BootstrapCapableStore {
set.add(hash);
}
async function putSelfReferencing(payload: unknown): Promise<Hash> {
const hash = await computeSelfHash(payload);
function putSelfReferencing(payload: unknown): Hash {
const hash = computeSelfHashSync(payload);
if (!data.has(hash)) {
data.set(hash, { type: hash, payload, timestamp: Date.now() });
indexHash(hash, hash);
@@ -39,15 +81,13 @@ export function createMemoryStore(): BootstrapCapableStore {
return result;
}
const store: BootstrapCapableStore = {
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
const hash = await computeHash(typeHash, payload);
const store: MemoryCasStore = {
put(typeHash: Hash, payload: unknown): Hash {
const hash = computeHashSync(typeHash, payload);
if (!data.has(hash)) {
data.set(hash, { type: typeHash, payload, timestamp: Date.now() });
indexHash(typeHash, hash);
}
return hash;
},
@@ -85,20 +125,19 @@ export function createMemoryStore(): BootstrapCapableStore {
return applyListOptions(entriesForHashes(result), options);
},
delete(hash: Hash): void {
delete(hash: Hash): boolean {
const node = data.get(hash);
if (node) {
data.delete(hash);
// Remove from type index
const set = byType.get(node.type);
if (set) {
set.delete(hash);
if (set.size === 0) {
byType.delete(node.type);
}
if (!node) return false;
data.delete(hash);
const set = byType.get(node.type);
if (set) {
set.delete(hash);
if (set.size === 0) {
byType.delete(node.type);
}
metaSet.delete(hash);
}
metaSet.delete(hash);
return true;
},
[BOOTSTRAP_STORE]: putSelfReferencing,
@@ -106,3 +145,334 @@ export function createMemoryStore(): BootstrapCapableStore {
return store;
}
/**
* Build an in-memory `VarStore` backed by the supplied CAS store. Exposed so
* non-Memory CAS stores (e.g. the FS store) can compose a full `Store`
* without re-implementing variable storage.
*/
export function createMemoryVarStoreFor(cas: CasStore): VarStore {
// composite key: `${name}\u0000${schema}`
const records = new Map<string, VarRecord>();
const byName = new Map<string, Set<string>>(); // name -> set of composite keys
const varStore: VarStore = {
set(name: string, hash: Hash, options?: VarSetOptions): Variable {
validateName(name);
const schema = extractSchema(cas, hash);
const k = varKey(name, schema);
const existing = records.get(k);
const now = Date.now();
if (existing) {
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const changed = pushHistory(existing, hash, now);
if (changed) {
existing.value = hash;
existing.updated = now;
}
if (options !== undefined) {
existing.tags = { ...tags };
existing.labels = [...labels];
}
return cloneVarRecord(existing);
}
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
checkTagLabelConflict(tags, labels);
const rec: VarRecord = {
name,
schema,
value: hash,
created: now,
updated: now,
tags: { ...tags },
labels: [...labels],
history: [{ value: hash, position: 0, setAt: now }],
};
records.set(k, rec);
addNameIndex(byName, name, k);
return cloneVarRecord(rec);
},
get(name: string, schema?: Hash): Variable | null {
if (schema !== undefined) {
const rec = records.get(varKey(name, schema));
return rec ? cloneVarRecord(rec) : null;
}
// No schema: if exactly one variant, return it; otherwise null
const set = byName.get(name);
if (!set || set.size !== 1) return null;
const onlyKey = set.values().next().value;
if (onlyKey === undefined) return null;
const rec = records.get(onlyKey);
return rec ? cloneVarRecord(rec) : null;
},
remove(name: string, schema?: Hash): Variable[] {
if (schema !== undefined) {
const k = varKey(name, schema);
const rec = records.get(k);
if (!rec) return [];
records.delete(k);
removeNameIndex(byName, name, k);
return [cloneVarRecord(rec)];
}
const set = byName.get(name);
if (!set) return [];
const removed: Variable[] = [];
for (const k of [...set]) {
const rec = records.get(k);
if (rec) {
removed.push(cloneVarRecord(rec));
records.delete(k);
}
}
byName.delete(name);
return removed;
},
update(name: string, hash: Hash, options?: VarSetOptions): Variable {
validateName(name);
const newSchema = extractSchema(cas, hash);
// Find existing record by name; require existing schema match new schema
const set = byName.get(name);
if (!set || set.size === 0) {
throw new VariableNotFoundError(name, newSchema);
}
// find a record matching newSchema
const k = varKey(name, newSchema);
const existing = records.get(k);
if (!existing) {
// Find any existing — schema mismatch
for (const ek of set) {
const erec = records.get(ek);
if (erec) {
throw new SchemaMismatchError(erec.schema, newSchema);
}
}
throw new VariableNotFoundError(name, newSchema);
}
const now = Date.now();
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const changed = pushHistory(existing, hash, now);
if (changed) {
existing.value = hash;
existing.updated = now;
}
if (options !== undefined) {
existing.tags = { ...tags };
existing.labels = [...labels];
}
return cloneVarRecord(existing);
},
list(options?: VarListOptions): Variable[] {
if (
options?.namePrefix !== undefined &&
options?.exactName !== undefined
) {
throw new Error(
"namePrefix and exactName are mutually exclusive - cannot specify both",
);
}
const namePrefix = options?.namePrefix;
const exactName = options?.exactName;
const schema = options?.schema;
const filterTags = options?.tags ?? {};
const filterLabels = options?.labels ?? [];
const sort = options?.sort ?? "created";
const desc = options?.desc ?? false;
const limit = options?.limit;
const offset = options?.offset ?? 0;
if (limit !== undefined && limit <= 0) return [];
let results: VarRecord[] = [];
for (const rec of records.values()) {
if (exactName !== undefined && rec.name !== exactName) continue;
if (namePrefix !== undefined && !rec.name.startsWith(namePrefix))
continue;
if (schema !== undefined && rec.schema !== schema) continue;
let ok = true;
for (const [tk, tv] of Object.entries(filterTags)) {
if (rec.tags[tk] !== tv) {
ok = false;
break;
}
}
if (!ok) continue;
for (const lb of filterLabels) {
if (!rec.labels.includes(lb)) {
ok = false;
break;
}
}
if (!ok) continue;
results.push(rec);
}
results.sort((a, b) => {
const av = sort === "updated" ? a.updated : a.created;
const bv = sort === "updated" ? b.updated : b.created;
if (av !== bv) return desc ? bv - av : av - bv;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});
if (offset > 0) results = results.slice(offset);
if (limit !== undefined) results = results.slice(0, limit);
return results.map(cloneVarRecord);
},
history(name: string, schema?: Hash): HistoryEntry[] {
if (schema !== undefined) {
const rec = records.get(varKey(name, schema));
return rec ? rec.history.map((e) => ({ ...e })) : [];
}
const set = byName.get(name);
if (!set || set.size !== 1) return [];
const onlyKey = set.values().next().value;
if (onlyKey === undefined) return [];
const rec = records.get(onlyKey);
return rec ? rec.history.map((e) => ({ ...e })) : [];
},
close(): void {
// no-op for in-memory store
},
};
return varStore;
}
/**
* Build an in-memory `TagStore`. Exposed for composition with non-Memory CAS
* stores.
*/
export function createMemoryTagStoreImpl(): TagStore {
// target -> key -> Tag
const byTarget = new Map<Hash, Map<string, Tag>>();
// key -> set of targets
const byKey = new Map<string, Set<Hash>>();
// per-target ordering (created)
const targetOrder = new Map<Hash, number>();
function addKeyIndex(key: string, target: Hash): void {
let set = byKey.get(key);
if (!set) {
set = new Set();
byKey.set(key, set);
}
set.add(target);
}
function removeKeyIndex(key: string, target: Hash): void {
const set = byKey.get(key);
if (!set) return;
// only remove if this target no longer has that key in any tag
const tmap = byTarget.get(target);
if (tmap && tmap.has(key)) return;
set.delete(target);
if (set.size === 0) byKey.delete(key);
}
return {
tag(target: Hash, operations: TagOp[]): Tag[] {
let tmap = byTarget.get(target);
if (!tmap) {
tmap = new Map();
byTarget.set(target, tmap);
}
const now = Date.now();
for (const op of operations) {
if (op.op === "set") {
const tag: Tag = {
key: op.key,
value: op.value ?? null,
target,
created: tmap.get(op.key)?.created ?? now,
};
tmap.set(op.key, tag);
addKeyIndex(op.key, target);
} else {
tmap.delete(op.key);
removeKeyIndex(op.key, target);
}
}
if (!targetOrder.has(target)) {
targetOrder.set(target, now);
}
// return the current tags
return [...tmap.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
untag(target: Hash, keys: string[]): void {
const tmap = byTarget.get(target);
if (!tmap) return;
for (const k of keys) {
tmap.delete(k);
removeKeyIndex(k, target);
}
if (tmap.size === 0) {
byTarget.delete(target);
targetOrder.delete(target);
}
},
tags(target: Hash): Tag[] {
const tmap = byTarget.get(target);
if (!tmap) return [];
return [...tmap.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
listByTag(tag: string, options?: ListOptions): Hash[] {
// accept "key" or "key=value" form
let key = tag;
let value: string | null | undefined;
const eqIdx = tag.indexOf("=");
if (eqIdx >= 0) {
key = tag.slice(0, eqIdx);
value = tag.slice(eqIdx + 1);
}
const targets = byKey.get(key);
if (!targets) return [];
let entries: ListEntry[] = [];
for (const t of targets) {
const tmap = byTarget.get(t);
if (!tmap) continue;
const tagEntry = tmap.get(key);
if (!tagEntry) continue;
if (value !== undefined && tagEntry.value !== value) continue;
entries.push(casListEntry(t, tagEntry.created));
}
entries = applyListOptions(entries, options);
return entries.map((e) => e.hash);
},
};
}
/**
* Create an in-memory `Store` with three sub-stores: `cas`, `var`, `tag`.
*
* The `cas` sub-store also satisfies the legacy `BootstrapCapableStore`
* contract it carries a `[BOOTSTRAP_STORE]` callable and a `listAll()`
* helper so existing helpers (`bootstrap`, `gc`, `render`, ) can be
* called with `store.cas` until they are migrated to the unified surface.
*/
export function createMemoryStore(): Store & {
cas: MemoryCasStore;
} {
const cas = createCasStore();
const varStore = createMemoryVarStoreFor(cas);
const tagStore = createMemoryTagStoreImpl();
return { cas, var: varStore, tag: tagStore };
}
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, test } from "bun:test";
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);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, test } from "bun:test";
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.dir, "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);
});
});
+87 -6
View File
@@ -1,3 +1,5 @@
import type { Variable } from "./variable.js";
/**
* 13-character uppercase Crockford Base32 string produced by XXH64.
*/
@@ -45,16 +47,95 @@ export type ListEntry = {
};
/**
* Content-addressable store interface.
* Self-referencing nodes are created only via bootstrap().
* Synchronous content-addressable store interface.
*/
export type Store = {
put(typeHash: Hash, payload: unknown): Promise<Hash>;
export type CasStore = {
get(hash: Hash): CasNode | null;
put(typeHash: Hash, payload: unknown): Hash;
has(hash: Hash): boolean;
delete(hash: Hash): boolean;
listByType(typeHash: Hash, options?: ListOptions): ListEntry[];
listAll(): Hash[];
listMeta(options?: ListOptions): ListEntry[];
listSchemas(options?: ListOptions): ListEntry[];
delete(hash: Hash): void;
listAll(): Hash[];
};
/**
* Options for setting/updating a variable.
*/
export type VarSetOptions = {
tags?: Record<string, string>;
labels?: string[];
};
/**
* Options for listing variables.
*/
export type VarListOptions = ListOptions & {
namePrefix?: string;
exactName?: string;
schema?: Hash;
tags?: Record<string, string>;
labels?: string[];
};
/**
* One entry in a variable's history (most-recent-first per `position`).
*/
export type HistoryEntry = {
value: Hash;
position: number;
setAt: number;
};
/**
* Variable store interface mutable bindings (name, schema) hash.
*/
export type VarStore = {
set(name: string, hash: Hash, options?: VarSetOptions): Variable;
get(name: string, schema?: Hash): Variable | null;
remove(name: string, schema?: Hash): Variable[];
update(name: string, hash: Hash, options?: VarSetOptions): Variable;
list(options?: VarListOptions): Variable[];
history(name: string, schema?: Hash): HistoryEntry[];
close(): void;
};
/**
* A tag attached to a CAS target. `value === null` indicates a label
* (bare identifier); otherwise a key-value tag.
*/
export type Tag = {
key: string;
value: string | null;
target: Hash;
created: number;
};
/**
* A tag mutation operation: set (with value) or delete (key only).
*/
export type TagOp = {
op: "set" | "delete";
key: string;
value?: string;
};
/**
* Tag store interface manages key-value tags and labels on CAS targets.
*/
export type TagStore = {
tag(target: Hash, operations: TagOp[]): Tag[];
untag(target: Hash, keys: string[]): void;
tags(target: Hash): Tag[];
listByTag(tag: string, options?: ListOptions): Hash[];
};
/**
* Aggregate OCAS store: bundles CAS, variable, and tag stores.
*/
export type Store = {
cas: CasStore;
var: VarStore;
tag: TagStore;
};
+49
View File
@@ -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)`,
);
}
}
}
+103
View File
@@ -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],
};
}
+257
View File
@@ -0,0 +1,257 @@
import { describe, expect, test } from "bun:test";
import {
CasNodeNotFoundError,
InvalidVariableNameError,
MAX_HISTORY,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
} from "./errors.js";
import { createMemoryStore } from "./store.js";
import type { Hash } from "./types.js";
import { validateName } from "./validation.js";
function makeStoreWithSchema(): {
store: ReturnType<typeof createMemoryStore>;
schema: Hash;
meta: Hash;
put: (payload: unknown) => Hash;
} {
const store = createMemoryStore();
const meta = store.cas[Symbol.for("@ocas/core/bootstrap-store")]({
type: "object",
}) as Hash;
const schema = store.cas.put(meta, { type: "string" });
return {
store,
schema,
meta,
put: (payload) => store.cas.put(schema, payload),
};
}
describe("In-memory VarStore", () => {
test("C1. set + get round-trip", () => {
const { store, schema, put } = makeStoreWithSchema();
const h = put("hello");
const v = store.var.set("@app/x", h);
expect(v.name).toBe("@app/x");
expect(v.value).toBe(h);
expect(v.schema).toBe(schema);
expect(typeof v.created).toBe("number");
expect(typeof v.updated).toBe("number");
expect(v.tags).toEqual({});
expect(v.labels).toEqual([]);
const got = store.var.get("@app/x", schema);
expect(got).not.toBeNull();
expect(got?.value).toBe(h);
expect(got?.schema).toBe(schema);
});
test("C2. name validation", () => {
const { store, put } = makeStoreWithSchema();
const h = put("v");
expect(() => store.var.set("x", h)).toThrow(InvalidVariableNameError);
expect(() => store.var.set("@/x", h)).toThrow(InvalidVariableNameError);
expect(() => store.var.set("@app/", h)).toThrow(InvalidVariableNameError);
expect(() => store.var.set("@app//x", h)).toThrow(InvalidVariableNameError);
expect(() => store.var.set("@app/x.y_z-1", h)).not.toThrow();
});
test("C3. set throws CasNodeNotFoundError if hash not in cas", () => {
const { store } = makeStoreWithSchema();
expect(() => store.var.set("@app/x", "ZZZZZZZZZZZZZ")).toThrow(
CasNodeNotFoundError,
);
});
test("C4. idempotent same-value set", async () => {
const { store, schema, put } = makeStoreWithSchema();
const h = put("v");
const v1 = store.var.set("@app/x", h);
await new Promise((r) => setTimeout(r, 5));
const v2 = store.var.set("@app/x", h);
expect(v2.updated).toBe(v1.updated);
expect(store.var.history("@app/x", schema)).toHaveLength(1);
});
test("C5. update via re-set with new value bumps updated and history", async () => {
const { store, schema, put } = makeStoreWithSchema();
const h1 = put("v1");
const h2 = put("v2");
const v1 = store.var.set("@app/x", h1);
await new Promise((r) => setTimeout(r, 5));
const v2 = store.var.set("@app/x", h2);
expect(v2.updated).toBeGreaterThan(v1.updated);
const hist = store.var.history("@app/x", schema);
expect(hist.map((e) => e.value)).toEqual([h2, h1]);
expect(hist[0]?.position).toBe(0);
expect(hist[1]?.position).toBe(1);
});
test("C6. history bound to MAX_HISTORY (10)", () => {
const { store, schema, put } = makeStoreWithSchema();
const hashes: Hash[] = [];
for (let i = 0; i < 12; i++) {
const h = put(`v${i}`);
hashes.push(h);
store.var.set("@app/x", h);
}
const hist = store.var.history("@app/x", schema);
expect(hist).toHaveLength(MAX_HISTORY);
expect(hist[0]?.value).toBe(hashes[11] as string);
});
test("C7. history rotation when re-setting an older value", () => {
const { store, schema, put } = makeStoreWithSchema();
const h1 = put("v1");
const h2 = put("v2");
const h3 = put("v3");
store.var.set("@app/x", h1);
store.var.set("@app/x", h2);
store.var.set("@app/x", h3);
expect(store.var.history("@app/x", schema).map((e) => e.value)).toEqual([
h3,
h2,
h1,
]);
store.var.set("@app/x", h1);
expect(store.var.history("@app/x", schema).map((e) => e.value)).toEqual([
h1,
h3,
h2,
]);
});
test("C8. tags + labels round-trip", () => {
const { store, schema, put } = makeStoreWithSchema();
const h = put("v");
store.var.set("@app/x", h, {
tags: { env: "prod" },
labels: ["pinned"],
});
const got = store.var.get("@app/x", schema);
expect(got?.tags).toEqual({ env: "prod" });
expect(got?.labels).toEqual(["pinned"]);
});
test("C9. tag/label conflict throws TagLabelConflictError", () => {
const { store, put } = makeStoreWithSchema();
const h = put("v");
expect(() =>
store.var.set("@app/x", h, {
tags: { x: "y" },
labels: ["x"],
}),
).toThrow(TagLabelConflictError);
});
test("C10. update requires existing variable and matching schema", () => {
const { store, put } = makeStoreWithSchema();
const h = put("v");
expect(() => store.var.update("@app/x", h)).toThrow(VariableNotFoundError);
store.var.set("@app/x", h);
// Different schema for new value
const meta = store.cas[Symbol.for("@ocas/core/bootstrap-store")]({
type: "object",
}) as Hash;
const otherSchema = store.cas.put(meta, { type: "number" });
const h2 = store.cas.put(otherSchema, 42);
expect(() => store.var.update("@app/x", h2)).toThrow(SchemaMismatchError);
});
test("C11. remove with and without schema", () => {
const { store, schema, put } = makeStoreWithSchema();
const h = put("v");
store.var.set("@app/x", h);
const removed = store.var.remove("@app/x", schema);
expect(removed).toHaveLength(1);
expect(removed[0]?.value).toBe(h);
expect(store.var.get("@app/x", schema)).toBeNull();
// remove(name) without schema returns array; empty if none
expect(store.var.remove("@app/none")).toEqual([]);
store.var.set("@app/y", put("y1"));
const removedAll = store.var.remove("@app/y");
expect(Array.isArray(removedAll)).toBe(true);
expect(removedAll).toHaveLength(1);
});
test("C12. list filters and ListOptions", () => {
const { store, schema, put } = makeStoreWithSchema();
const h1 = put("v1");
const h2 = put("v2");
store.var.set("@app/a", h1, { tags: { env: "prod" } });
store.var.set("@app/b", h2, { labels: ["pinned"] });
expect(() =>
store.var.list({ namePrefix: "@app", exactName: "@app/a" }),
).toThrow();
const byPrefix = store.var.list({ namePrefix: "@app/" });
expect(byPrefix.map((v) => v.name).sort()).toEqual(["@app/a", "@app/b"]);
const exact = store.var.list({ exactName: "@app/a" });
expect(exact).toHaveLength(1);
const byTag = store.var.list({ tags: { env: "prod" } });
expect(byTag.map((v) => v.name)).toEqual(["@app/a"]);
const byLabel = store.var.list({ labels: ["pinned"] });
expect(byLabel.map((v) => v.name)).toEqual(["@app/b"]);
const bySchema = store.var.list({ schema });
expect(bySchema).toHaveLength(2);
const limited = store.var.list({ limit: 1 });
expect(limited).toHaveLength(1);
});
test("C13. close() is a no-op", () => {
const { store } = makeStoreWithSchema();
expect(() => store.var.close()).not.toThrow();
});
test("C14. cas.delete does NOT cascade-delete the variable", () => {
const { store, schema, put } = makeStoreWithSchema();
const h = put("v");
store.var.set("@app/x", h);
store.cas.delete(h);
const got = store.var.get("@app/x", schema);
expect(got).not.toBeNull();
expect(got?.value).toBe(h);
});
});
describe("validateName (shared)", () => {
test("C-VN1. accepts well-formed names", () => {
expect(() => validateName("@app/x")).not.toThrow();
expect(() => validateName("@app/a.b_c-1")).not.toThrow();
expect(() => validateName("@app/nested/path")).not.toThrow();
expect(() => validateName("@ocas/schema")).not.toThrow();
});
test("C-VN2. rejects empty / missing-@ / @ -only / trailing slash / double slash", () => {
expect(() => validateName("")).toThrow(InvalidVariableNameError);
expect(() => validateName("x")).toThrow(InvalidVariableNameError);
expect(() => validateName("@/x")).toThrow(InvalidVariableNameError);
expect(() => validateName("@app/")).toThrow(InvalidVariableNameError);
expect(() => validateName("@app//x")).toThrow(InvalidVariableNameError);
});
test("C-VN3. rejects invalid segment characters", () => {
expect(() => validateName("@app/foo bar")).toThrow(
InvalidVariableNameError,
);
expect(() => validateName("@app/foo!bar")).toThrow(
InvalidVariableNameError,
);
});
test("C-VN4. scope must start with a letter", () => {
expect(() => validateName("@1bad/x")).toThrow(InvalidVariableNameError);
});
});
@@ -1,108 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap } from "./bootstrap.js";
import { createMemoryStore } from "./store.js";
import type { Hash } from "./types.js";
import { createVariableStore, type VariableStore } from "./variable-store.js";
let dbDir: string;
let dbPath: string;
let casStore: ReturnType<typeof createMemoryStore>;
let varStore: VariableStore;
let stringHash: Hash;
beforeEach(async () => {
dbDir = mkdtempSync(join(tmpdir(), "ocas-var-pagination-"));
dbPath = join(dbDir, "vars.db");
casStore = createMemoryStore();
const aliases = await bootstrap(casStore);
stringHash = aliases["@ocas/string"] as Hash;
varStore = createVariableStore(dbPath, casStore);
});
afterEach(() => {
varStore.close();
rmSync(dbDir, { recursive: true, force: true });
});
async function setN(prefix: string, n: number, delayMs = 2): Promise<Hash[]> {
const hashes: Hash[] = [];
for (let i = 0; i < n; i++) {
const h = await casStore.put(stringHash, `${prefix}-${i}`);
varStore.set(`@test/${prefix}-${i}`, h);
hashes.push(h);
if (delayMs > 0 && i < n - 1) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
return hashes;
}
describe("VariableStore.list - pagination + sort", () => {
test("D1. default sort = created ASC", async () => {
await setN("v", 3);
const list = varStore.list({ namePrefix: "@test/v-" });
for (let i = 1; i < list.length; i++) {
expect((list[i] as { created: number }).created).toBeGreaterThanOrEqual(
(list[i - 1] as { created: number }).created,
);
}
});
test("D2. sort: 'updated' differs after re-set", async () => {
await setN("u", 3);
await new Promise((r) => setTimeout(r, 5));
// Re-set u-0 with a NEW value so updated changes
const newHash = await casStore.put(stringHash, "u-0-new");
varStore.set("@test/u-0", newHash);
const byUpdated = varStore.list({
namePrefix: "@test/u-",
sort: "updated",
});
// u-0 should be last when sorted updated ASC
const last = byUpdated[byUpdated.length - 1] as { name: string };
expect(last.name).toBe("@test/u-0");
});
test("D3. desc reverses both sort modes", async () => {
await setN("d", 3);
const asc = varStore.list({ namePrefix: "@test/d-" });
const desc = varStore.list({ namePrefix: "@test/d-", desc: true });
expect(desc[0]).toEqual(asc[asc.length - 1] as (typeof asc)[number]);
});
test("D4. limit/offset honored", async () => {
await setN("p", 5);
expect(varStore.list({ namePrefix: "@test/p-", limit: 2 })).toHaveLength(2);
expect(
varStore.list({ namePrefix: "@test/p-", offset: 2, limit: 10 }),
).toHaveLength(3);
});
test("D5. core has no default limit (returns all)", async () => {
await setN("big", 105, 0);
const list = varStore.list({ namePrefix: "@test/big-" });
expect(list).toHaveLength(105);
});
test("D6. pagination applied AFTER namePrefix/schema filters", async () => {
await setN("filt", 5);
const list = varStore.list({
namePrefix: "@test/filt-",
schema: stringHash,
limit: 2,
});
expect(list).toHaveLength(2);
for (const v of list) {
expect((v as { name: string }).name.startsWith("@test/filt-")).toBe(true);
}
});
test("limit: 0 returns empty array", async () => {
await setN("z", 3, 0);
expect(varStore.list({ namePrefix: "@test/z-", limit: 0 })).toEqual([]);
});
});
File diff suppressed because it is too large Load Diff
-884
View File
@@ -1,884 +0,0 @@
import { Database } from "bun:sqlite";
import type { Hash, ListSort, Store } from "./types.js";
import type { Variable } from "./variable.js";
/**
* Maximum number of historical values retained per (variable_name, variable_schema).
* Position 0 is current; positions 1..MAX_HISTORY-1 are previous values (LRU).
*/
export const MAX_HISTORY = 10;
/**
* Custom error types for variable operations
*/
export class VariableNotFoundError extends Error {
constructor(
public variableName: string,
public variableSchema: Hash,
) {
super(`Variable not found: name=${variableName}, schema=${variableSchema}`);
this.name = "VariableNotFoundError";
}
}
export class InvalidVariableNameError extends Error {
constructor(
public variableName: string,
public reason: string,
) {
super(`Invalid variable name "${variableName}": ${reason}`);
this.name = "InvalidVariableNameError";
}
}
export class SchemaMismatchError extends Error {
constructor(
public expected: string,
public actual: string,
) {
super(`Schema mismatch: expected ${expected}, got ${actual}`);
this.name = "SchemaMismatchError";
}
}
export class CasNodeNotFoundError extends Error {
constructor(
public readonly hash: string,
message?: string,
) {
super(message ?? `CAS node not found: ${hash}`);
this.name = "CasNodeNotFoundError";
}
}
export class TagLabelConflictError extends Error {
constructor(
public conflictName: string,
public existingType: "tag" | "label",
public attemptedType: "tag" | "label",
) {
super(`Conflict: '${conflictName}' already exists as a ${existingType}`);
this.name = "TagLabelConflictError";
}
}
export class InvalidTagFormatError extends Error {
constructor(tag: string) {
super(`Invalid tag format: ${tag}`);
this.name = "InvalidTagFormatError";
}
}
/**
* Variable store with SQLite backend
*/
export class VariableStore {
private db: Database;
constructor(
dbPath: string,
private casStore: Store,
) {
this.db = new Database(dbPath, { create: true });
// Enable foreign keys
this.db.exec("PRAGMA foreign_keys = ON");
this.initDb();
}
private initDb(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS variables (
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
PRIMARY KEY (name, schema)
);
CREATE INDEX IF NOT EXISTS idx_var_name ON variables(name);
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
CREATE TABLE IF NOT EXISTS variable_tags (
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (variable_name, variable_schema, key),
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS variable_labels (
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (variable_name, variable_schema, name),
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key);
CREATE INDEX IF NOT EXISTS idx_var_tag_key_value ON variable_tags(key, value);
CREATE INDEX IF NOT EXISTS idx_var_label_name ON variable_labels(name);
CREATE TABLE IF NOT EXISTS variable_history (
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
value TEXT NOT NULL,
position INTEGER NOT NULL,
set_at INTEGER NOT NULL,
PRIMARY KEY (variable_name, variable_schema, position),
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_var_history_value ON variable_history(value);
`);
}
/**
* Validate variable name format.
* All names must follow @scope/name pattern:
* - scope: @[a-zA-Z][a-zA-Z0-9]* (e.g. @myapp, @ocas)
* - name: one or more segments of [a-zA-Z0-9._-]+ separated by /
* Examples: @myapp/config, @todo/schema, @ocas/schema
*/
private validateName(name: string): void {
if (name === "") {
throw new InvalidVariableNameError(name, "Name cannot be empty");
}
// Must match @scope/name where scope starts with a letter
const match = name.match(/^@([a-zA-Z][a-zA-Z0-9]*)\/(.+)$/);
if (!match) {
throw new InvalidVariableNameError(
name,
"Name must follow @scope/name format (e.g. @myapp/config)",
);
}
const rest = match[2] as string;
// Validate remaining segments
if (rest.endsWith("/")) {
throw new InvalidVariableNameError(
name,
"Name cannot end with trailing slash",
);
}
const segments = rest.split("/");
for (const segment of segments) {
if (segment === "") {
throw new InvalidVariableNameError(
name,
"Name contains empty segment (consecutive slashes //)",
);
}
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
throw new InvalidVariableNameError(
name,
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
);
}
}
}
/**
* Extract schema hash from CAS node
*/
private extractSchema(hash: string): string {
const node = this.casStore.get(hash);
if (node === null) {
throw new CasNodeNotFoundError(hash);
}
return node.type;
}
/**
* Load tags for a variable
*/
private loadTags(name: string, schema: Hash): Record<string, string> {
const stmt = this.db.prepare(`
SELECT key, value
FROM variable_tags
WHERE variable_name = ? AND variable_schema = ?
`);
const rows = stmt.all(name, schema) as Array<{
key: string;
value: string;
}>;
const tags: Record<string, string> = {};
for (const row of rows) {
tags[row.key] = row.value;
}
return tags;
}
/**
* Load labels for a variable
*/
private loadLabels(name: string, schema: Hash): string[] {
const stmt = this.db.prepare(`
SELECT name
FROM variable_labels
WHERE variable_name = ? AND variable_schema = ?
ORDER BY name ASC
`);
const rows = stmt.all(name, schema) as Array<{ name: string }>;
return rows.map((row) => row.name);
}
/**
* Manage history for a variable on set().
*
* Rules:
* - If new value equals current (position 0), no-op (idempotent).
* - If new value already exists in history at position N, remove it; entries
* with position < N shift +1; insert new value at position 0.
* - Otherwise shift all entries +1, insert new at position 0, prune any
* entries at position >= MAX_HISTORY.
*
* Caller must invoke inside a transaction.
* Returns true if history changed (i.e. value differs from current),
* false if it was a no-op.
*/
private recordHistory(
name: string,
schema: Hash,
value: Hash,
now: number,
): boolean {
// Check current value at position 0
const currentRow = this.db
.prepare(
`SELECT value FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = 0`,
)
.get(name, schema) as { value: string } | undefined | null;
if (currentRow && currentRow.value === value) {
// Idempotent: same value as current; do nothing
return false;
}
// Find existing position of this value (if any)
const existingRow = this.db
.prepare(
`SELECT position FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND value = ?`,
)
.get(name, schema, value) as { position: number } | undefined | null;
if (existingRow) {
const existingPos = existingRow.position;
// Delete the existing entry first to free its position
this.db
.prepare(
`DELETE FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = ?`,
)
.run(name, schema, existingPos);
// Shift positions [0, existingPos) up by 1.
// Use a temporary offset to avoid PK conflicts during the shift.
this.db
.prepare(
`UPDATE variable_history SET position = position + 1000000 WHERE variable_name = ? AND variable_schema = ? AND position < ?`,
)
.run(name, schema, existingPos);
this.db
.prepare(
`UPDATE variable_history SET position = position - 1000000 + 1 WHERE variable_name = ? AND variable_schema = ? AND position >= 1000000`,
)
.run(name, schema);
} else {
// New value: shift everything +1 (using temp offset to avoid PK conflicts)
this.db
.prepare(
`UPDATE variable_history SET position = position + 1000000 WHERE variable_name = ? AND variable_schema = ?`,
)
.run(name, schema);
this.db
.prepare(
`UPDATE variable_history SET position = position - 1000000 + 1 WHERE variable_name = ? AND variable_schema = ? AND position >= 1000000`,
)
.run(name, schema);
// Prune any entries that ended up at position >= MAX_HISTORY
this.db
.prepare(
`DELETE FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position >= ?`,
)
.run(name, schema, MAX_HISTORY);
}
// Insert new value at position 0
this.db
.prepare(
`INSERT INTO variable_history (variable_name, variable_schema, value, position, set_at) VALUES (?, ?, ?, 0, ?)`,
)
.run(name, schema, value, now);
return true;
}
/**
* Set a variable (upsert: create or update)
*/
set(
name: string,
value: string,
options?: {
tags?: Record<string, string>;
labels?: string[];
},
): Variable {
// Validate name format
this.validateName(name);
const schema = this.extractSchema(value);
// Check if variable exists
const existing = this.get(name, schema);
if (existing !== null) {
// Update existing variable
const now = Date.now();
// If options provided, use them; otherwise preserve existing
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
// Check for tag/label conflicts when updating with new options
if (options !== undefined) {
const tagKeys = Object.keys(tags);
for (const key of tagKeys) {
if (labels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
}
this.db.exec("BEGIN TRANSACTION");
let changed = false;
try {
// Manage history (also detects idempotent same-value sets)
changed = this.recordHistory(name, schema, value, now);
// Update value and timestamp only if value changed
if (changed) {
const updateStmt = this.db.prepare(`
UPDATE variables
SET value = ?, updated = ?
WHERE name = ? AND schema = ?
`);
updateStmt.run(value, now, name, schema);
}
// If options provided, update tags/labels
if (options !== undefined) {
// Delete existing tags and labels
this.db
.prepare(`
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ?
`)
.run(name, schema);
this.db
.prepare(`
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ?
`)
.run(name, schema);
// Insert new tags
const tagKeys = Object.keys(tags);
if (tagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, val] of Object.entries(tags)) {
tagStmt.run(name, schema, key, val);
}
}
// Insert new labels
if (labels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const labelName of labels) {
labelStmt.run(name, schema, labelName);
}
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
return {
name,
schema,
value,
created: existing.created,
updated: changed ? now : existing.updated,
tags,
labels: [...labels],
};
}
// Create new variable
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
// Check for tag/label conflicts
const tagKeys = Object.keys(tags);
for (const key of tagKeys) {
if (labels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
const now = Date.now();
this.db.exec("BEGIN TRANSACTION");
try {
const stmt = this.db.prepare(`
INSERT INTO variables (name, schema, value, created, updated)
VALUES (?, ?, ?, ?, ?)
`);
stmt.run(name, schema, value, now, now);
// Initialise history with this value at position 0
this.db
.prepare(
`INSERT INTO variable_history (variable_name, variable_schema, value, position, set_at) VALUES (?, ?, ?, 0, ?)`,
)
.run(name, schema, value, now);
// Insert tags
if (tagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, val] of Object.entries(tags)) {
tagStmt.run(name, schema, key, val);
}
}
// Insert labels
if (labels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const labelName of labels) {
labelStmt.run(name, schema, labelName);
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
return {
name,
schema,
value,
created: now,
updated: now,
tags,
labels: [...labels],
};
}
/**
* Get a variable by name, optionally with schema
*/
/**
* Get a variable by name and schema
* @param name - Variable name
* @param schema - Schema hash (required)
* @returns Variable if found, null otherwise
*/
get(name: string, schema: Hash): Variable | null {
// Precise match with schema
const stmt = this.db.prepare(`
SELECT name, schema, value, created, updated
FROM variables
WHERE name = ? AND schema = ?
`);
const row = stmt.get(name, schema) as
| {
name: string;
schema: string;
value: string;
created: number;
updated: number;
}
| undefined
| null;
if (row === undefined || row === null) {
return null;
}
const tags = this.loadTags(row.name, row.schema);
const labels = this.loadLabels(row.name, row.schema);
return {
name: row.name,
schema: row.schema,
value: row.value,
created: row.created,
updated: row.updated,
tags,
labels,
};
}
/**
* Update a variable's value (with schema validation)
*/
update(name: string, schema: Hash, value: string): Variable {
// Validate name format
this.validateName(name);
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(name, schema);
}
const newSchema = this.extractSchema(value);
if (newSchema !== existing.schema) {
throw new SchemaMismatchError(existing.schema, newSchema);
}
const now = Date.now();
const stmt = this.db.prepare(`
UPDATE variables
SET value = ?, updated = ?
WHERE name = ? AND schema = ?
`);
stmt.run(value, now, name, schema);
return {
...existing,
value,
updated: now,
};
}
/**
* Remove a variable (or all variants if schema omitted)
*/
remove(name: string): Variable[];
remove(name: string, schema: Hash): Variable;
remove(name: string, schema?: Hash): Variable | Variable[] {
if (schema !== undefined) {
// Remove specific (name, schema) variant
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(name, schema);
}
const stmt = this.db.prepare(`
DELETE FROM variables WHERE name = ? AND schema = ?
`);
stmt.run(name, schema);
return existing;
}
// Remove all schema variants for this name
const variants = this.list({
exactName: name,
});
if (variants.length === 0) {
return [];
}
const stmt = this.db.prepare(`
DELETE FROM variables WHERE name = ?
`);
stmt.run(name);
return variants;
}
/**
* List variables with optional filters
*/
list(options?: {
namePrefix?: string;
exactName?: string;
schema?: Hash;
tags?: Record<string, string>;
labels?: string[];
sort?: ListSort;
desc?: boolean;
limit?: number;
offset?: number;
}): Variable[] {
// Validate mutually exclusive options
if (options?.namePrefix !== undefined && options?.exactName !== undefined) {
throw new Error(
"namePrefix and exactName are mutually exclusive - cannot specify both",
);
}
const namePrefix = options?.namePrefix ?? "";
const exactName = options?.exactName;
const schema = options?.schema;
const filterTags = options?.tags ?? {};
const filterLabels = options?.labels ?? [];
const sort = options?.sort ?? "created";
const desc = options?.desc ?? false;
const limit = options?.limit;
const offset = options?.offset ?? 0;
if (limit !== undefined && limit <= 0) return [];
// Build query with filters
let query = `
SELECT DISTINCT v.name, v.schema, v.value, v.created, v.updated
FROM variables v
`;
const params: (string | number)[] = [];
// Tag filters (AND logic)
const tagKeys = Object.keys(filterTags);
for (let i = 0; i < tagKeys.length; i++) {
const key = tagKeys[i] as string;
const value = filterTags[key] as string;
query += `
INNER JOIN variable_tags t${i} ON v.name = t${i}.variable_name
AND v.schema = t${i}.variable_schema
AND t${i}.key = ? AND t${i}.value = ?
`;
params.push(key, value);
}
// Label filters (AND logic)
for (let i = 0; i < filterLabels.length; i++) {
const label = filterLabels[i] as string;
query += `
INNER JOIN variable_labels l${i} ON v.name = l${i}.variable_name
AND v.schema = l${i}.variable_schema
AND l${i}.name = ?
`;
params.push(label);
}
// WHERE clause for name filters and schema
const whereClauses: string[] = [];
if (exactName !== undefined) {
whereClauses.push("v.name = ?");
params.push(exactName);
} else if (namePrefix !== "") {
whereClauses.push("v.name LIKE ? || '%'");
params.push(namePrefix);
}
if (schema !== undefined) {
whereClauses.push("v.schema = ?");
params.push(schema);
}
if (whereClauses.length > 0) {
query += ` WHERE ${whereClauses.join(" AND ")}`;
}
const sortColumn = sort === "updated" ? "v.updated" : "v.created";
const direction = desc ? "DESC" : "ASC";
// Tiebreaker: name ASC for stable ordering across same-ms timestamps
query += ` ORDER BY ${sortColumn} ${direction}, v.name ASC`;
if (limit !== undefined) {
query += " LIMIT ? OFFSET ?";
params.push(limit, offset);
} else if (offset > 0) {
// SQLite requires LIMIT when using OFFSET; use -1 to mean "no limit".
query += " LIMIT -1 OFFSET ?";
params.push(offset);
}
const stmt = this.db.prepare(query);
const rows = stmt.all(...params) as Array<{
name: string;
schema: string;
value: string;
created: number;
updated: number;
}>;
return rows.map((row) => ({
name: row.name,
schema: row.schema,
value: row.value,
created: row.created,
updated: row.updated,
tags: this.loadTags(row.name, row.schema),
labels: this.loadLabels(row.name, row.schema),
}));
}
/**
* Add/update/delete tags and labels
*/
tag(
name: string,
schema: Hash,
operations: {
add?: Record<string, string>; // tags to add/update
addLabels?: string[]; // labels to add
delete?: string[]; // tag keys or label names to delete
},
): Variable {
// Validate name format
this.validateName(name);
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(name, schema);
}
const addTags = operations.add ?? {};
const addLabels = operations.addLabels ?? [];
const deleteNames = operations.delete ?? [];
// Check for conflicts between tags and labels
const newTagKeys = Object.keys(addTags);
for (const key of newTagKeys) {
// Check if this key is being added as a label in the same operation
if (addLabels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
// Check if this key already exists as a label (and not being deleted)
if (existing.labels.includes(key) && !deleteNames.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
for (const labelName of addLabels) {
// Check if this name is being added as a tag in the same operation
if (newTagKeys.includes(labelName)) {
throw new TagLabelConflictError(labelName, "tag", "label");
}
// Check if this name already exists as a tag key (and not being deleted)
if (
existing.tags[labelName] !== undefined &&
!deleteNames.includes(labelName)
) {
throw new TagLabelConflictError(labelName, "tag", "label");
}
}
const now = Date.now();
this.db.exec("BEGIN TRANSACTION");
try {
// Update timestamp
const updateStmt = this.db.prepare(`
UPDATE variables SET updated = ? WHERE name = ? AND schema = ?
`);
updateStmt.run(now, name, schema);
// Delete tags and labels
if (deleteNames.length > 0) {
const deleteTagStmt = this.db.prepare(`
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ? AND key = ?
`);
const deleteLabelStmt = this.db.prepare(`
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ? AND name = ?
`);
for (const deleteName of deleteNames) {
deleteTagStmt.run(name, schema, deleteName);
deleteLabelStmt.run(name, schema, deleteName);
}
}
// Add or update tags
if (newTagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT OR REPLACE INTO variable_tags (variable_name, variable_schema, key, value)
VALUES (?, ?, ?, ?)
`);
for (const [key, value] of Object.entries(addTags)) {
tagStmt.run(name, schema, key, value);
}
}
// Add labels (with conflict handling)
if (addLabels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT OR IGNORE INTO variable_labels (variable_name, variable_schema, name)
VALUES (?, ?, ?)
`);
for (const labelName of addLabels) {
labelStmt.run(name, schema, labelName);
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
// Return updated variable
const updated = this.get(name, schema);
if (updated === null) {
throw new VariableNotFoundError(name, schema);
}
return updated;
}
/**
* Get the value history for a variable, ordered by position.
* Index 0 is the current value; subsequent entries are older.
* Returns an empty array if the variable does not exist.
*/
history(name: string, schema: Hash): Hash[] {
const rows = this.db
.prepare(
`SELECT value, position FROM variable_history WHERE variable_name = ? AND variable_schema = ? ORDER BY position ASC`,
)
.all(name, schema) as Array<{ value: string; position: number }>;
return rows.map((r) => r.value as Hash);
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
}
/**
* Create a variable store
*/
export function createVariableStore(
dbPath: string,
casStore: Store,
): VariableStore {
return new VariableStore(dbPath, casStore);
}
+7 -7
View File
@@ -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",
+2 -2
View File
@@ -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}`);
+156 -171
View File
@@ -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,183 +257,169 @@ 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);
expect(async () => putSchema(store, { type: "garbage" })).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),
bootstrap(store);
expect(async () =>
putSchema(store, { type: 123 } as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
type: "object",
properties: "not-an-object",
} as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
type: "object",
required: "name",
} as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
type: "object",
required: ["name", 123, true],
} as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
type: "object",
additionalProperties: "yes",
} as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
anyOf: { type: "string" },
} as unknown as JSONSchema),
).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);
expect(async () => putSchema(store, { anyOf: [] })).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
type: "array",
items: "string",
} as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
type: "string",
format: 123,
} as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
type: "string",
enum: "red",
} as unknown as JSONSchema),
).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: [] }),
bootstrap(store);
expect(async () =>
putSchema(store, { type: "string", enum: [] }),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
type: "string",
title: 123,
} as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
type: "string",
description: ["not a string"],
} as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
$ref: "#/definitions/user",
} as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, "not-a-schema" as unknown as JSONSchema),
).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),
bootstrap(store);
expect(async () =>
putSchema(store, {
type: "object",
properties: {
name: { type: "invalid-type" },
},
} as unknown as JSONSchema),
).toThrow();
});
});
@@ -441,9 +427,9 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
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,22 +628,21 @@ 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),
expect(async () =>
putSchema(store, { type: 123 } as unknown as JSONSchema),
).toThrow();
});
});
@@ -665,7 +650,7 @@ describe("Test Suite 7: Meta-Schema Content Validation", () => {
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
View File
@@ -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.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@ocas/fs",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -20,7 +20,7 @@
},
"dependencies": {
"cborg": "^4.2.3",
"@ocas/core": "0.1.0"
"@ocas/core": "0.2.0"
},
"repository": {
"type": "git",
+72 -42
View File
@@ -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(29);
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);
@@ -260,8 +260,8 @@ describe("createFsStore – listByType", () => {
});
test("bootstrap node is listed under its self type after reload", async () => {
const store1 = createFsStore(dir);
const builtinSchemas = await bootstrap(store1);
const store1 = await openStore(dir);
const builtinSchemas = bootstrap(store1);
const hash = builtinSchemas["@ocas/schema"] ?? "";
const store2 = createFsStore(dir);
@@ -294,8 +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);
});
});
@@ -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");
});
});
+79 -74
View File
@@ -10,29 +10,32 @@ import {
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type {
BootstrapCapableStore,
CasNode,
Hash,
ListEntry,
ListOptions,
VariableStore,
} from "@ocas/core";
import {
applyListOptions,
BOOTSTRAP_STORE,
type BootstrapCapableStore,
bootstrap,
type CasNode,
casListEntry,
cborEncode,
computeHash,
computeSelfHash,
computeHashSync,
computeSelfHashSync,
type Hash,
initHasher,
type ListEntry,
type ListOptions,
type Store,
} from "@ocas/core";
import { decode } from "cborg";
import { createFsTagStore, createFsVarStoreFor } from "./var-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 {
@@ -190,15 +193,24 @@ function hashesToEntries(
return result;
}
export function createFsStore(dir: string): BootstrapCapableStore {
/**
* 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);
@@ -218,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 = {
@@ -279,43 +291,43 @@ export function createFsStore(dir: string): BootstrapCapableStore {
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,
@@ -325,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 });
@@ -374,25 +383,21 @@ 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 ocas: Store = {
cas,
var: createFsVarStoreFor(dir, cas),
tag: createFsTagStore(dir),
};
bootstrap(ocas);
return ocas;
}
+136
View File
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, 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 + JSONL 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 jsonl = join(dir, "_tags.jsonl");
expect(existsSync(jsonl)).toBe(true);
const content = readFileSync(jsonl, "utf8");
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines).toHaveLength(1);
const parsed = JSON.parse(lines[0] as string) as {
key: string;
value: string;
};
expect(parsed.key).toBe("env");
expect(parsed.value).toBe("prod");
});
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. JSONL 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");
});
});
+194
View File
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, 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 JSONL", 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 jsonl = join(dir, "_vars.jsonl");
expect(existsSync(jsonl)).toBe(true);
const content = readFileSync(jsonl, "utf8");
expect(content.length).toBeGreaterThan(0);
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines.length).toBeGreaterThanOrEqual(1);
const matching = lines
.map((l) => JSON.parse(l) as { name?: string; value?: Hash })
.find((r) => r.name === "@app/x");
expect(matching).toBeDefined();
expect(matching?.value).toBe(h);
});
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,
);
});
});
+418
View File
@@ -0,0 +1,418 @@
import {
appendFileSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type {
CasStore,
Hash,
ListEntry,
Tag,
TagStore,
Variable,
VarListOptions,
VarStore,
} from "@ocas/core";
import {
addNameIndex,
applyListOptions,
casListEntry,
checkTagLabelConflict,
cloneVarRecord,
extractSchema,
pushHistory,
removeNameIndex,
SchemaMismatchError,
VariableNotFoundError,
type VarRecord,
validateName,
varKey,
} from "@ocas/core";
const VARS_FILE = "_vars.jsonl";
const TAGS_FILE = "_tags.jsonl";
export function createFsVarStoreFor(dir: string, cas: CasStore): VarStore {
const records = new Map<string, VarRecord>();
const byName = new Map<string, Set<string>>();
const path = join(dir, VARS_FILE);
// Load existing records (last record per key wins)
try {
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
}
}
} catch {
// file may not exist
}
function persistFull(): void {
mkdirSync(dir, { recursive: true });
const lines: string[] = [];
for (const rec of records.values()) {
lines.push(JSON.stringify(rec));
}
writeFileSync(path, lines.length ? `${lines.join("\n")}\n` : "", "utf8");
}
function appendRecord(rec: VarRecord): void {
mkdirSync(dir, { recursive: true });
appendFileSync(path, `${JSON.stringify(rec)}\n`, "utf8");
}
function appendRemoval(name: string, schema: Hash): void {
mkdirSync(dir, { recursive: true });
appendFileSync(
path,
`${JSON.stringify({ __op: "remove", name, schema })}\n`,
"utf8",
);
}
return {
set(name, hash, options) {
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];
}
persistFull();
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);
appendRecord(rec);
return cloneVarRecord(rec);
},
get(name, schema) {
if (schema !== undefined) {
const rec = records.get(varKey(name, schema));
return rec ? cloneVarRecord(rec) : 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, schema) {
if (schema !== undefined) {
const k = varKey(name, schema);
const rec = records.get(k);
if (!rec) return [];
records.delete(k);
removeNameIndex(byName, name, k);
appendRemoval(name, schema);
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);
appendRemoval(rec.name, rec.schema);
}
}
byName.delete(name);
return removed;
},
update(name, hash, options) {
validateName(name);
const newSchema = extractSchema(cas, hash);
const set = byName.get(name);
if (!set || set.size === 0)
throw new VariableNotFoundError(name, newSchema);
const k = varKey(name, newSchema);
const existing = records.get(k);
if (!existing) {
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];
}
persistFull();
return cloneVarRecord(existing);
},
list(options?: VarListOptions) {
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, schema) {
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() {
// no-op (synchronous file ops)
},
};
}
type StoredTag = {
key: string;
value: string | null;
target: Hash;
created: number;
};
export function createFsTagStore(dir: string): TagStore {
const byTarget = new Map<Hash, Map<string, Tag>>();
const byKey = new Map<string, Set<Hash>>();
const path = join(dir, TAGS_FILE);
function addKeyIndex(k: string, target: Hash): void {
let set = byKey.get(k);
if (!set) {
set = new Set();
byKey.set(k, set);
}
set.add(target);
}
function removeKeyIndex(k: string, target: Hash): void {
const set = byKey.get(k);
if (!set) return;
const tmap = byTarget.get(target);
if (tmap?.has(k)) return;
set.delete(target);
if (set.size === 0) byKey.delete(k);
}
// Load
try {
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);
removeKeyIndex(e.key, e.target);
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,
});
addKeyIndex(t.key, t.target);
}
} catch {
// skip
}
}
} catch {
// none
}
function append(line: object): void {
mkdirSync(dir, { recursive: true });
appendFileSync(path, `${JSON.stringify(line)}\n`, "utf8");
}
return {
tag(target, ops) {
let tm = byTarget.get(target);
if (!tm) {
tm = new Map();
byTarget.set(target, tm);
}
const now = Date.now();
for (const op of ops) {
if (op.op === "set") {
const existing = tm.get(op.key);
const tag: Tag = {
key: op.key,
value: op.value ?? null,
target,
created: existing?.created ?? now,
};
tm.set(op.key, tag);
addKeyIndex(op.key, target);
append(tag);
} else {
tm.delete(op.key);
removeKeyIndex(op.key, target);
append({ __op: "untag", target, key: op.key });
}
}
return [...tm.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
untag(target, keys) {
const tm = byTarget.get(target);
if (!tm) return;
for (const k of keys) {
tm.delete(k);
removeKeyIndex(k, target);
append({ __op: "untag", target, key: k });
}
if (tm.size === 0) byTarget.delete(target);
},
tags(target) {
const tm = byTarget.get(target);
if (!tm) return [];
return [...tm.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
listByTag(tag, options) {
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 tm = byTarget.get(t);
if (!tm) continue;
const tagEntry = tm.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);
},
};
}
+19 -1
View File
@@ -71,6 +71,23 @@ info "New version: $NEW_VERSION"
git branch -m "release/next" "release/$NEW_VERSION"
info "Release branch: release/$NEW_VERSION"
# --- Clean up changesets on main ---
info "Removing consumed changesets from main..."
git stash --include-untracked
git checkout main
# Delete the changeset files that were consumed
for cs in $CHANGESETS; do
rm -f "$cs"
done
git add -A
if [[ -n "$(git status --porcelain)" ]]; then
git commit -m "chore: remove changesets consumed by release/$NEW_VERSION"
git push origin main
fi
git checkout "release/$NEW_VERSION"
git stash pop || true
# --- Validate ---
echo ""
@@ -101,5 +118,6 @@ echo ""
echo " Next steps:"
echo " 1. Review changes: git diff main...HEAD"
echo " 2. Fix issues if needed, commit to this branch"
echo " 3. When ready: ./scripts/publish.sh"
echo " 3. Prerelease: ./scripts/publish.sh --rc"
echo " 4. Final release: ./scripts/publish.sh"
echo ""
+119 -38
View File
@@ -4,7 +4,8 @@ set -euo pipefail
# OCAS Publish
# Builds, publishes to npm, tags, and pushes.
#
# Usage: ./scripts/publish.sh
# Usage: ./scripts/publish.sh # publish final release
# ./scripts/publish.sh --rc # publish prerelease (rc tag)
#
# Prerequisites:
# - On a release/* branch (created by prepare-release.sh)
@@ -21,6 +22,13 @@ info() { echo -e "${BOLD}${GREEN}✓${NC} $1"; }
warn() { echo -e "${BOLD}${YELLOW}${NC} $1"; }
error() { echo -e "${BOLD}${RED}${NC} $1"; exit 1; }
# --- Parse flags ---
RC_MODE=false
if [[ "${1:-}" == "--rc" ]]; then
RC_MODE=true
fi
# --- Pre-flight checks ---
echo -e "\n${BOLD}OCAS Publish${NC}\n"
@@ -32,14 +40,31 @@ BRANCH=$(git branch --show-current)
# Clean working tree
[[ -z "$(git status --porcelain)" ]] || error "Working tree is not clean. Commit changes first."
# Extract version
VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
info "Publishing version: $VERSION"
# Extract base version from package.json
BASE_VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
# No pending changesets (should have been consumed by prepare-release.sh)
CHANGESETS=$(ls .changeset/*.md 2>/dev/null | grep -v README.md || true)
if [[ -n "$CHANGESETS" ]]; then
error "Pending changesets found. Run prepare-release.sh first."
# Determine publish version and npm tag
if [[ "$RC_MODE" == "true" ]]; then
# Find next rc number
EXISTING_RC=$(npm view "@ocas/core" versions --json 2>/dev/null | python3 -c "
import json, sys, re
versions = json.load(sys.stdin)
if not isinstance(versions, list): versions = [versions]
rc_nums = [int(m.group(1)) for v in versions if (m := re.match(r'^${BASE_VERSION}-rc\.(\d+)$', v))]
print(max(rc_nums) + 1 if rc_nums else 1)
" 2>/dev/null || echo 1)
VERSION="${BASE_VERSION}-rc.${EXISTING_RC}"
NPM_TAG="rc"
info "Prerelease mode: $VERSION (npm tag: rc)"
else
VERSION="$BASE_VERSION"
NPM_TAG="latest"
# No pending changesets (should have been consumed by prepare-release.sh)
CHANGESETS=$(ls .changeset/*.md 2>/dev/null | grep -v README.md || true)
if [[ -n "$CHANGESETS" ]]; then
error "Pending changesets found. Run prepare-release.sh first."
fi
fi
# npm auth check
@@ -47,6 +72,24 @@ npm whoami &>/dev/null || error "Not authenticated with npm. Run 'npm login' fir
NPM_USER=$(npm whoami)
info "npm user: $NPM_USER"
# --- Set version in all packages ---
info "Setting version: $VERSION"
for pkg in core fs cli; do
python3 -c "
import json
path = 'packages/$pkg/package.json'
data = json.load(open(path))
data['version'] = '$VERSION'
# Fix workspace:* to actual version for publishing
for dep in list(data.get('dependencies', {})):
if data['dependencies'][dep] == 'workspace:*':
data['dependencies'][dep] = '$VERSION'
json.dump(data, open(path, 'w'), indent=2)
open(path, 'a').write('\n')
"
done
# --- Final validation ---
info "Running final validation..."
@@ -57,53 +100,91 @@ bun run build
echo " → bun test"
bun test || error "Tests failed! Fix before publishing."
# --- Confirm ---
# --- Publish (order matters: core → fs → cli) ---
echo ""
echo -e "${BOLD}Will publish:${NC}"
for pkg in core fs cli; do
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
echo " $PKG_NAME@$VERSION"
echo " $PKG_NAME@$VERSION (tag: $NPM_TAG)"
done
# --- Publish (order matters: core → fs → cli) ---
for pkg in core fs cli; do
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
echo ""
info "Publishing $PKG_NAME@$VERSION..."
(cd "packages/$pkg" && bun publish --access public)
(cd "packages/$pkg" && bun publish --access public --tag "$NPM_TAG")
info "$PKG_NAME@$VERSION published ✓"
done
# --- Tag and push ---
# --- Commit version changes ---
echo ""
TAG="v$VERSION"
git tag -a "$TAG" -m "Release $TAG"
git push origin "$BRANCH"
git push origin "$TAG"
info "Tag $TAG pushed"
git add -A
if [[ -n "$(git diff --cached --name-only)" ]]; then
git commit -m "chore(release): set version $VERSION"
fi
# --- Merge back to main ---
# --- For final release: tag, push, merge back ---
echo ""
info "Merging release into main..."
git checkout main
git merge "$BRANCH" --no-ff -m "chore: merge release $TAG"
git push origin main
info "Merged to main"
if [[ "$RC_MODE" == "false" ]]; then
TAG="v$VERSION"
git tag -a "$TAG" -m "Release $TAG"
git push origin "$BRANCH" --tags
git push github "$BRANCH" --tags 2>/dev/null || true
info "Tag $TAG pushed"
# Clean up release branch
git branch -d "$BRANCH"
git push origin --delete "$BRANCH" 2>/dev/null || true
info "Release branch cleaned up"
# Merge back to main
echo ""
info "Merging release into main..."
git checkout main
git merge "$BRANCH" --no-ff -m "chore: merge release $TAG"
echo ""
info "Release $TAG complete! 🎉"
echo ""
echo " Published:"
for pkg in core fs cli; do
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
echo " https://www.npmjs.com/package/$PKG_NAME"
done
# Restore workspace:* on main
for pkg in fs cli; do
python3 -c "
import json
path = 'packages/$pkg/package.json'
data = json.load(open(path))
for dep in list(data.get('dependencies', {})):
if dep.startswith('@ocas/'):
data['dependencies'][dep] = 'workspace:*'
json.dump(data, open(path, 'w'), indent=2)
open(path, 'a').write('\n')
"
done
git add -A
git commit -m "chore: restore workspace:* deps on main" || true
git push origin main
git push github main 2>/dev/null || true
info "Merged to main"
# Clean up release branch
git branch -d "$BRANCH"
git push origin --delete "$BRANCH" 2>/dev/null || true
git push github --delete "$BRANCH" 2>/dev/null || true
info "Release branch cleaned up"
echo ""
info "Release $TAG complete! 🎉"
echo ""
echo " Published:"
for pkg in core fs cli; do
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
echo " https://www.npmjs.com/package/$PKG_NAME"
done
else
# RC: just push the branch
git push origin "$BRANCH"
git push github "$BRANCH" 2>/dev/null || true
echo ""
info "Prerelease $VERSION published! 🏷️"
echo ""
echo " Install for testing:"
echo " bun add -g @ocas/cli@rc"
echo ""
echo " When verified, run final release:"
echo " ./scripts/publish.sh"
fi
echo ""