Compare commits

..

21 Commits

Author SHA1 Message Date
xiaomo 0706307e85 fix: align all packages to 0.5.0 and restore workspace:^ deps
- All @uncaged/* packages → 0.5.0 (fixed versioning per changesets config)
- Restore workspace:^ for internal deps (was broken to ^0.3.0/^0.4.0)
- Regenerate bun.lock (removes duplicate npm registry entries)
2026-05-25 04:05:17 +00:00
xiaoju d57a454b78 docs: add CLAUDE.md with project conventions and code standards 2026-05-25 04:03:08 +00:00
xiaomo 34847cae59 Merge pull request 'feat!: self-validating meta-schema for putSchema' (#16) from fix/15-self-validating-meta-schema into main 2026-05-25 03:53:11 +00:00
xiaoju 054d78296a feat!: self-validating meta-schema for putSchema
Replace bootstrap payload with a JSON Schema meta-schema describing
our supported schema subset. putSchema now validates input schemas
against the meta-schema before storing, rejecting invalid schemas
with SchemaValidationError.

- bootstrap.ts: self-describing meta-schema (type, properties, required,
  additionalProperties, anyOf, items, format, title, enum, const, description)
- schema.ts: recursive isValidSchema(), SchemaValidationError class
- index.ts: export SchemaValidationError
- package.json: bump 0.4.0 → 1.0.0 (breaking change)

BREAKING CHANGE: meta-schema hash changed, old CAS data invalid.

Fixes #15
2026-05-25 03:51:43 +00:00
xiaoju 8f54dcfa7c chore: remove hardcoded paths from solve-issue workflow
Use git rev-parse, git worktree list, and git remote to detect
paths dynamically. No more ~/repos/ assumptions.

— 小橘 🍊(NEKO Team)
2026-05-25 01:43:40 +00:00
xiaoju 7828dd1c41 chore: generalize solve-issue workflow for any repo
Remove hardcoded ~/repos/workflow paths. Use git rev-parse and
basename to detect repo root and worktree base dynamically.

— 小橘 🍊(NEKO Team)
2026-05-25 01:40:48 +00:00
xiaoju a30af6efb5 chore: use workflow project's solve-issue with tester/committer
Replace minimal version with full 5-role workflow from workflow project.

— 小橘 🍊(NEKO Team)
2026-05-25 01:36:50 +00:00
xiaoju 712f930072 chore: add solve-issue workflow definition
— 小橘 🍊(NEKO Team)
2026-05-25 01:35:27 +00:00
scottwei cfe791180b Merge pull request 'feat: remove Store.list() from interface' (#14) from issue-11-remove-store-list into main
Reviewed-on: #14
2026-05-25 01:35:02 +00:00
xiaoju 09526b63da fix: json-cas-fs and json-cas-workflow depend on json-cas ^0.4.0
小橘 🍊(NEKO Team)
2026-05-19 10:21:41 +00:00
xiaoju 00f191e105 chore: bump to 0.4.0
小橘 🍊(NEKO Team)
2026-05-19 10:19:38 +00:00
xiaoju 52cb7a30ba fix: publish compiled .js + .d.ts instead of raw .ts sources
- Add tsc --build pipeline for json-cas, json-cas-fs, json-cas-workflow
- Update package.json exports to point to dist/ (types + import)
- Fix Store type error: use BootstrapCapableStore for stores with bootstrap
- Export BootstrapCapableStore type from json-cas
- Fix meta-schema: nodeSchema now uses real JSON Schema (draft 2020-12)
- Exclude test files from tsc compilation

Breaking: bootstrap hash changes due to meta-schema payload update.

小橘 🍊(NEKO Team)
2026-05-19 10:18:31 +00:00
Scott Wei 1b53cf5ff8 feat: remove Store.list() from interface
Removes the list() method from the Store type and all implementations.
Callers now use listByType() or has() instead.

The CLI 'list' subcommand is removed. 'schema list' now uses
listByType(metaHash) to enumerate schemas.

Closes #11
2026-05-19 01:24:12 +08:00
xiaoju 17ed619900 chore: release @uncaged/* 0.3.0 2026-05-18 15:00:40 +00:00
xiaomo bad62a82a9 Merge pull request 'feat: disallow self-referencing nodes except via bootstrap()' (#13) from feat/12-no-null-type into main 2026-05-18 14:59:17 +00:00
xiaoju 4989cd31ba Remove null from Store.put typeHash parameter
Self-referencing nodes are created only through bootstrap() via an internal BOOTSTRAP_STORE symbol on memory and fs store implementations. put() always requires a Hash typeHash and uses computeHash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:40 +00:00
xiaoju 38aad696fc chore: release @uncaged/* 0.2.0 2026-05-18 14:15:33 +00:00
xiaomo fb81f5a429 Merge pull request 'feat: add listByType(typeHash) to Store interface' (#10) from feat/9-list-by-type into main 2026-05-18 14:13:16 +00:00
xiaoju 5fc475704b feat: add listByType(typeHash) to Store interface
Implement in-memory type index and fs append-only _index files. Rebuild index from existing .bin nodes on first load when _index is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:11:53 +00:00
xiaoju 6e77e4a110 fix: replace workspace:^ deps with real versions + merge cli global store path
- All packages bumped to 0.1.3 via changesets (fixed group)
- Replace workspace:^ with ^0.1.x in json-cas-fs, json-cas-workflow, cli-json-cas
- Merge PR #8: cli default store path → ~/.uncaged/json-cas/

Co-authored-by: 星月 🌙 (SORA Team)
小橘 🍊(NEKO Team)
2026-05-18 10:47:12 +00:00
xiaoju a3a21b153c fix: replace workspace:^ with ^0.1.1 in json-cas-fs deps 2026-05-18 10:43:22 +00:00
32 changed files with 1700 additions and 93 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/changelog-github",
"changelog": ["@changesets/changelog-github", { "repo": "uncaged/json-cas" }],
"commit": false,
"fixed": [["@uncaged/*"]],
"linked": [],
+1
View File
@@ -1,3 +1,4 @@
node_modules/
dist/
*.d.ts.map
dist/
+224
View File
@@ -0,0 +1,224 @@
name: "solve-issue"
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
roles:
planner:
description: "Analyzes issue and outputs a TDD test spec"
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
capabilities:
- issue-analysis
- planning
procedure: |
On first run (no previous steps):
1. Read the issue and all comments from Gitea using `tea issues <number>`
2. Read project conventions (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) to understand coding standards
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number>` (skip if you already commented), then output status=insufficient_info and terminate
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
On subsequent runs (bounced back by tester with fix_spec):
1. Read the tester's output from the previous step to understand what's wrong with the spec
2. Revise the test spec accordingly
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when status=ready)
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
frontmatter:
type: object
properties:
status:
type: string
enum: [ready, insufficient_info]
plan:
type: string
required: [status]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
capabilities:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
Set up variables from the current working directory:
```
REPO_ROOT=$(git rev-parse --show-toplevel)
WORKTREE_BASE=$(dirname $REPO_ROOT)/$(basename $REPO_ROOT)-worktrees
```
Before starting any work, set up an isolated worktree:
1. `cd $REPO_ROOT && git fetch origin` to get latest refs
2. First time (no existing branch):
- `git worktree add $WORKTREE_BASE/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd $WORKTREE_BASE/fix/<issue-number>-<short-slug> && bun install`
3. If bounced back from reviewer or tester (branch already exists):
- `cd $WORKTREE_BASE/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
4. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
5. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
6. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
7. Write tests first based on the spec
8. Implement the code to make tests pass
9. Ensure `bun run build` passes with no errors
10. Run `bun test` to verify all tests pass
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
frontmatter:
type: object
properties:
status:
type: string
enum: [done, failed]
required: [status]
reviewer:
description: "Code standards compliance check"
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
capabilities:
- code-review
- static-analysis
procedure: |
Find and cd into the worktree directory for this issue.
Before reviewing, verify the git branch:
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
2. If the branch doesn't correspond to the issue, flag it in your output and reject
Then perform code review:
Hard checks (must all pass):
3. `bun run build` — no build errors
4. `bunx biome check` — no lint violations
5. TypeScript strict mode — no type errors
Soft checks (review against project conventions file if present):
- Code style and naming conventions
- Module organization
- No debug logging left behind
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
frontmatter:
type: object
properties:
approved:
type: boolean
required: [approved]
tester:
description: "Functional correctness verification"
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
capabilities:
- testing
procedure: |
Find and cd into the worktree directory for this issue.
1. Run `bun test` for automated test verification
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
- fix_code: tests fail or implementation doesn't match spec → send back to developer
- fix_spec: the spec itself is wrong or incomplete → send back to planner
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
frontmatter:
type: object
properties:
status:
type: string
enum: [passed, fix_code, fix_spec]
required: [status]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
Find and cd into the worktree directory for this issue.
Set up variables:
```
OWNER_REPO=$(git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/')
REPO_ROOT=$(git rev-parse --show-toplevel)
MAIN_REPO=$(cd $REPO_ROOT && git worktree list --porcelain | head -1 | sed 's/worktree //')
```
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --repo $OWNER_REPO --title "..." --description "..."`
- The `--repo` flag is required to work in worktree directories
- PR description must follow: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, log the error clearly, include PR details for manual creation, and mark success=false
5. After PR creation, clean up the worktree:
- `cd $MAIN_REPO`
- `git worktree remove $REPO_ROOT`
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
frontmatter:
type: object
properties:
success:
type: boolean
required: [success]
conditions:
insufficientInfo:
description: "Planner determined there's not enough info to proceed"
expression: "$last('planner').status = 'insufficient_info'"
devFailed:
description: "Developer failed to implement"
expression: "$last('developer').status = 'failed'"
rejected:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
fixCode:
description: "Tester found code issues"
expression: "$last('tester').status = 'fix_code'"
fixSpec:
description: "Tester found spec issues"
expression: "$last('tester').status = 'fix_spec'"
hookFailed:
description: "Push hook failed"
expression: "$last('committer').success = false"
graph:
$START:
- role: "planner"
condition: null
prompt: "Analyze the issue and produce an implementation plan."
planner:
- role: "$END"
condition: "insufficientInfo"
prompt: "Insufficient information to proceed; end the workflow."
- role: "developer"
condition: null
prompt: "Implement the plan from the planner."
developer:
- role: "$END"
condition: "devFailed"
prompt: "Development failed; end the workflow."
- role: "reviewer"
condition: null
prompt: "Send the implementation to the reviewer."
reviewer:
- role: "developer"
condition: "rejected"
prompt: "Reviewer rejected the implementation; fix the issues."
- role: "tester"
condition: null
prompt: "Review passed; run tests on the implementation."
tester:
- role: "developer"
condition: "fixCode"
prompt: "Tests found code issues; return to developer."
- role: "planner"
condition: "fixSpec"
prompt: "Tests found spec issues; return to planner."
- role: "committer"
condition: null
prompt: "Tests passed; commit and push the changes."
committer:
- role: "developer"
condition: "hookFailed"
prompt: "Push hook failed; return to developer to fix."
- role: "$END"
condition: null
prompt: "Commit succeeded; complete the workflow."
+74
View File
@@ -0,0 +1,74 @@
# CLAUDE.md — json-cas
Self-describing content-addressable storage with JSON Schema typed nodes.
## Project Structure
Monorepo with 4 packages under `packages/`:
| Package | Description |
|---------|-------------|
| `json-cas` | Core CAS engine — hashing, schema, store, verify, bootstrap |
| `json-cas-fs` | Filesystem-backed CAS store |
| `json-cas-workflow` | Workflow integration layer (schemas + types) |
| `cli-json-cas` | CLI tool |
## Tech Stack
- **Runtime:** Bun
- **Language:** TypeScript (strict mode, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
- **Build:** `tsc --build` (composite project references)
- **Test:** `bun test`
- **Lint/Format:** Biome (`biome check .` / `biome format --write .`)
- **Publish:** Changesets → npmjs (`@uncaged/*`)
## Commands
```bash
bun test # Run all tests
bun run build # Build all packages
bun run check # Biome lint
bun run format # Biome format (auto-fix)
```
## Code Conventions
### TypeScript
- **Strict mode** — no `any`, no unchecked index access, no implicit overrides
- **`verbatimModuleSyntax`** — use `import type` for type-only imports
- **Import paths** — use `.js` extension in imports (ESM convention with bundler resolution)
- **Export style** — named exports only, re-export from `index.ts`
### Biome Rules
- `noConsole: "error"` globally (except `cli-json-cas`)
- Recommended ruleset enabled
- Auto-organize imports via `assist.actions.source.organizeImports`
- Indent: 2 spaces
### Naming
- Types: `PascalCase` (`CasNode`, `Hash`, `Store`)
- Functions: `camelCase` (`computeHash`, `createMemoryStore`)
- Constants: `UPPER_SNAKE_CASE` (`BOOTSTRAP_STORE`)
- Files: `kebab-case.ts`
- Test files: co-located as `*.test.ts`
### Key Types
- `Hash` — 13-character uppercase Crockford Base32 string (XXH64)
- `CasNode` — content-addressed node with schema
- `Store` — abstract storage interface (get/put)
## Git
- Commit format: `type: description` (conventional commits)
- Reference issues: `Fixes #N` / `Closes #N`
- Author: `小橘 <xiaoju@shazhou.work>`
## Before Submitting
1. `bun test` — all tests pass
2. `bun run check` — no lint errors
3. `bun run build` — builds cleanly
+4 -4
View File
@@ -14,7 +14,7 @@
},
"packages/cli-json-cas": {
"name": "@uncaged/cli-json-cas",
"version": "0.1.0",
"version": "0.5.0",
"bin": {
"json-cas": "./src/index.ts",
},
@@ -25,7 +25,7 @@
},
"packages/json-cas": {
"name": "@uncaged/json-cas",
"version": "0.1.0",
"version": "0.5.0",
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
@@ -34,7 +34,7 @@
},
"packages/json-cas-fs": {
"name": "@uncaged/json-cas-fs",
"version": "0.1.0",
"version": "0.5.0",
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"cborg": "^4.2.3",
@@ -42,7 +42,7 @@
},
"packages/json-cas-workflow": {
"name": "@uncaged/json-cas-workflow",
"version": "0.1.0",
"version": "0.5.0",
"dependencies": {
"@uncaged/json-cas": "workspace:^",
},
+1
View File
@@ -12,6 +12,7 @@
"typescript": "^5.8.0"
},
"scripts": {
"build": "tsc --build packages/json-cas packages/json-cas-fs packages/json-cas-workflow",
"test": "bun test",
"check": "biome check .",
"format": "biome format --write ."
+27
View File
@@ -0,0 +1,27 @@
# @uncaged/cli-json-cas
## 0.3.0
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.3.0
- @uncaged/json-cas-fs@0.3.0
## 0.2.0
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.2.0
- @uncaged/json-cas-fs@0.2.0
## 0.1.3
### Patch Changes
- fix: replace workspace:^ with actual version numbers in published dependencies
- Updated dependencies []:
- @uncaged/json-cas@0.1.3
- @uncaged/json-cas-fs@0.1.3
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-json-cas",
"version": "0.1.1",
"version": "0.5.0",
"type": "module",
"bin": {
"json-cas": "./src/index.ts"
+3 -13
View File
@@ -116,10 +116,10 @@ async function cmdSchemaGet(args: string[]): Promise<void> {
async function cmdSchemaList(): Promise<void> {
const store = openStore();
const metaHash = await bootstrap(store);
for (const hash of store.list()) {
for (const hash of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null && node.type === metaHash) {
if (node !== null) {
const schema = node.payload as JSONSchema;
const name =
(schema.title as string | undefined) ??
@@ -176,12 +176,7 @@ async function cmdVerify(args: string[]): Promise<void> {
console.log(ok ? "ok" : "corrupted");
}
async function cmdList(): Promise<void> {
const store = openStore();
for (const hash of store.list()) {
console.log(hash);
}
}
async function cmdRefs(args: string[]): Promise<void> {
const hash = args[0];
@@ -271,7 +266,6 @@ Commands:
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
list List all hashes
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
@@ -337,10 +331,6 @@ switch (cmd) {
await cmdVerify(rest);
break;
case "list":
await cmdList();
break;
case "refs":
await cmdRefs(rest);
break;
+30
View File
@@ -0,0 +1,30 @@
# @uncaged/json-cas-fs
## 0.3.0
### Minor Changes
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.3.0
## 0.2.0
### Minor Changes
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.2.0
## 0.1.3
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.1.3
+8 -3
View File
@@ -1,11 +1,16 @@
{
"name": "@uncaged/json-cas-fs",
"version": "0.1.1",
"version": "0.5.0",
"type": "module",
"main": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "src"],
"scripts": {
"test": "bun test"
},
+92 -11
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasNode } from "@uncaged/json-cas";
@@ -30,15 +30,15 @@ describe("createFsStore – init and bootstrap", () => {
test("store opens against an existing empty dir", () => {
const store = createFsStore(dir);
expect(store.list()).toEqual([]);
expect(store.listByType("0000000000000")).toEqual([]);
});
test("store creates the directory on first put", async () => {
const nested = join(dir, "sub", "store");
const store = createFsStore(nested);
const typeHash = await computeSelfHash({ name: "t" });
await store.put(typeHash, { x: 1 });
expect(store.list()).toHaveLength(1);
const hash = await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
});
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
@@ -58,7 +58,7 @@ describe("createFsStore – init and bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toBe(h2);
expect(store.list()).toHaveLength(1);
expect(store.listByType(h1)).toHaveLength(1);
});
});
@@ -84,7 +84,7 @@ describe("createFsStore – persistence round-trip", () => {
const store2 = createFsStore(dir);
expect(store2.has(h1)).toBe(true);
expect(store2.has(h2)).toBe(true);
expect(store2.list()).toHaveLength(2);
expect(store2.listByType(typeHash)).toHaveLength(2);
});
test("round-trip preserves type, payload, and timestamp", async () => {
@@ -125,7 +125,7 @@ describe("createFsStore – persistence round-trip", () => {
const ts2 = store2.get(hash)?.timestamp;
expect(ts1).toBe(ts2);
expect(store2.list()).toHaveLength(1);
expect(store2.listByType(typeHash)).toHaveLength(1);
});
});
@@ -151,7 +151,7 @@ describe("createFsStore – has and list", () => {
expect(store.has(hash)).toBe(true);
});
test("list returns all stored hashes", async () => {
test("listByType returns all stored hashes for a type", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
@@ -159,16 +159,16 @@ describe("createFsStore – has and list", () => {
const h2 = await store.put(typeHash, { a: 2 });
const h3 = await store.put(typeHash, { a: 3 });
const all = store.list();
const all = store.listByType(typeHash);
expect(all).toHaveLength(3);
expect(all).toContain(h1);
expect(all).toContain(h2);
expect(all).toContain(h3);
});
test("list returns empty array on fresh store", () => {
test("listByType returns empty array on fresh store", () => {
const store = createFsStore(dir);
expect(store.list()).toEqual([]);
expect(store.listByType("0000000000000")).toEqual([]);
});
test("get returns null for unknown hash", () => {
@@ -177,6 +177,87 @@ describe("createFsStore – has and list", () => {
});
});
// ──────────────────────────────────────────────────────────────────────────────
// listByType and index migration
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – listByType", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("returns empty array for unknown type", () => {
const store = createFsStore(dir);
expect(store.listByType("0000000000000")).toEqual([]);
});
test("returns all hashes for the given type", async () => {
const store = createFsStore(dir);
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 byType = store.listByType(typeHash);
expect(byType).toHaveLength(2);
expect(byType).toContain(h1);
expect(byType).toContain(h2);
});
test("listByType survives round-trip across store instances", async () => {
const typeHash = await computeSelfHash({ name: "persist-by-type" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { x: 1 });
const h2 = await store1.put(typeHash, { x: 2 });
const store2 = createFsStore(dir);
const byType = store2.listByType(typeHash);
expect(byType).toHaveLength(2);
expect(byType).toContain(h1);
expect(byType).toContain(h2);
});
test("idempotent put does not duplicate in listByType", async () => {
const typeHash = await computeSelfHash({ name: "idempotent-index" });
const store1 = createFsStore(dir);
const hash = await store1.put(typeHash, { n: 7 });
await store1.put(typeHash, { n: 7 });
const store2 = createFsStore(dir);
expect(store2.listByType(typeHash)).toEqual([hash]);
});
test("rebuilds _index from .bin files when index is missing", async () => {
const typeHash = await computeSelfHash({ name: "migrate" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { a: 1 });
const h2 = await store1.put(typeHash, { a: 2 });
rmSync(join(dir, "_index"), { recursive: true, force: true });
const store2 = createFsStore(dir);
expect(store2.listByType(typeHash)).toEqual([h1, h2]);
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
expect(readdirSync(join(dir, "_index"))).toContain(typeHash);
});
test("bootstrap node is listed under its self type after reload", async () => {
const store1 = createFsStore(dir);
const hash = await bootstrap(store1);
const store2 = createFsStore(dir);
expect(store2.listByType(hash)).toEqual([hash]);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// verify on disk-loaded nodes
// ──────────────────────────────────────────────────────────────────────────────
+121 -14
View File
@@ -1,4 +1,6 @@
import {
appendFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
@@ -6,11 +8,22 @@ import {
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type { CasNode, Hash, Store } from "@uncaged/json-cas";
import type {
BootstrapCapableStore,
CasNode,
Hash,
} from "@uncaged/json-cas";
import { cborEncode, computeHash, computeSelfHash } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
cborEncode,
computeHash,
computeSelfHash,
} from "@uncaged/json-cas";
import { decode } from "cborg";
const INDEX_DIR = "_index";
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
let entries: string[];
try {
@@ -31,20 +44,108 @@ function loadDir(dir: string, data: Map<Hash, CasNode>): void {
}
}
export function createFsStore(dir: string): Store {
function parseIndexFile(content: string): Hash[] {
if (content.length === 0) return [];
return content.split("\n").filter((line) => line.length > 0) as Hash[];
}
function loadTypeIndex(indexDir: string): Map<Hash, Hash[]> {
const typeIndex = new Map<Hash, Hash[]>();
let entries: string[];
try {
entries = readdirSync(indexDir);
} catch {
return typeIndex;
}
for (const typeHash of entries) {
try {
const content = readFileSync(join(indexDir, typeHash), "utf8");
typeIndex.set(typeHash as Hash, parseIndexFile(content));
} catch {
// skip unreadable index files
}
}
return typeIndex;
}
function buildTypeIndexFromNodes(data: Map<Hash, CasNode>): Map<Hash, Hash[]> {
const typeIndex = new Map<Hash, Hash[]>();
for (const [hash, node] of data) {
const list = typeIndex.get(node.type) ?? [];
list.push(hash);
typeIndex.set(node.type, list);
}
return typeIndex;
}
function writeTypeIndex(indexDir: string, typeIndex: Map<Hash, Hash[]>): void {
mkdirSync(indexDir, { recursive: true });
for (const [typeHash, hashes] of typeIndex) {
const body = hashes.length > 0 ? `${hashes.join("\n")}\n` : "";
writeFileSync(join(indexDir, typeHash), body, "utf8");
}
}
function loadOrMigrateTypeIndex(
dir: string,
data: Map<Hash, CasNode>,
): Map<Hash, Hash[]> {
const indexDir = join(dir, INDEX_DIR);
if (!existsSync(indexDir)) {
const typeIndex = buildTypeIndexFromNodes(data);
if (typeIndex.size > 0) {
writeTypeIndex(indexDir, typeIndex);
}
return typeIndex;
}
return loadTypeIndex(indexDir);
}
function appendToTypeIndex(
indexDir: string,
typeIndex: Map<Hash, Hash[]>,
type: Hash,
hash: Hash,
): void {
mkdirSync(indexDir, { recursive: true });
appendFileSync(join(indexDir, type), `${hash}\n`, "utf8");
const list = typeIndex.get(type) ?? [];
list.push(hash);
typeIndex.set(type, list);
}
export function createFsStore(dir: string): BootstrapCapableStore {
const data = new Map<Hash, CasNode>();
loadDir(dir, data);
const indexDir = join(dir, INDEX_DIR);
const typeIndex = loadOrMigrateTypeIndex(dir, data);
return {
async put(typeHash: Hash | null, payload: unknown): Promise<Hash> {
const hash =
typeHash === null
? await computeSelfHash(payload)
: await computeHash(typeHash, payload);
async function putSelfReferencing(payload: unknown): Promise<Hash> {
const hash = await computeSelfHash(payload);
if (!data.has(hash)) {
const node: CasNode = { type: hash, payload, timestamp: Date.now() };
data.set(hash, node);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `${hash}.tmp`);
const dest = join(dir, `${hash}.bin`);
writeFileSync(
tmp,
cborEncode({ type: hash, payload, timestamp: node.timestamp }),
);
renameSync(tmp, dest);
appendToTypeIndex(indexDir, typeIndex, hash, hash);
}
return hash;
}
const store: BootstrapCapableStore = {
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
const hash = await computeHash(typeHash, payload);
if (!data.has(hash)) {
const type = typeHash === null ? hash : typeHash;
const node: CasNode = { type, payload, timestamp: Date.now() };
const node: CasNode = { type: typeHash, payload, timestamp: Date.now() };
data.set(hash, node);
mkdirSync(dir, { recursive: true });
@@ -52,9 +153,11 @@ export function createFsStore(dir: string): Store {
const dest = join(dir, `${hash}.bin`);
writeFileSync(
tmp,
cborEncode({ type, payload, timestamp: node.timestamp }),
cborEncode({ type: typeHash, payload, timestamp: node.timestamp }),
);
renameSync(tmp, dest);
appendToTypeIndex(indexDir, typeIndex, typeHash, hash);
}
return hash;
@@ -68,8 +171,12 @@ export function createFsStore(dir: string): Store {
return data.has(hash);
},
list(): Hash[] {
return [...data.keys()];
listByType(typeHash: Hash): Hash[] {
return typeIndex.get(typeHash) ?? [];
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
return store;
}
+5 -1
View File
@@ -4,5 +4,9 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"],
"references": [
{ "path": "../json-cas" }
]
}
File diff suppressed because one or more lines are too long
+24
View File
@@ -0,0 +1,24 @@
# @uncaged/json-cas-workflow
## 0.3.0
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.3.0
## 0.2.0
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.2.0
## 0.1.3
### Patch Changes
- fix: replace workspace:^ with actual version numbers in published dependencies
- Updated dependencies []:
- @uncaged/json-cas@0.1.3
+8 -3
View File
@@ -1,11 +1,16 @@
{
"name": "@uncaged/json-cas-workflow",
"version": "0.1.1",
"version": "0.5.0",
"type": "module",
"main": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "src"],
"scripts": {
"test": "bun test"
},
+5 -1
View File
@@ -4,5 +4,9 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"],
"references": [
{ "path": "../json-cas" }
]
}
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
# @uncaged/json-cas
## 0.3.0
### Minor Changes
- Disallow self-referencing nodes in put(). typeHash is now required (no null). Self-ref only via bootstrap().
## 0.2.0
### Minor Changes
- Add listByType(typeHash) to Store interface for O(1) type-based queries, with append-only fs index
## 0.1.3
+8 -3
View File
@@ -1,11 +1,16 @@
{
"name": "@uncaged/json-cas",
"version": "0.1.1",
"version": "0.5.0",
"type": "module",
"main": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "src"],
"scripts": {
"test": "bun test"
},
@@ -0,0 +1,16 @@
import type { Hash, Store } from "./types.js";
/** @internal Store implementations attach this for bootstrap() only. */
export const BOOTSTRAP_STORE = Symbol.for("@uncaged/json-cas/bootstrap-store");
export type BootstrapCapableStore = Store & {
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash>;
};
export function isBootstrapCapableStore(
store: Store,
): store is BootstrapCapableStore {
return (
typeof (store as BootstrapCapableStore)[BOOTSTRAP_STORE] === "function"
);
}
+54 -12
View File
@@ -1,20 +1,59 @@
import {
BOOTSTRAP_STORE,
isBootstrapCapableStore,
} from "./bootstrap-capable.js";
import type { Hash, Store } from "./types.js";
const JSON_SCHEMA_TYPES = [
"string",
"number",
"integer",
"boolean",
"object",
"array",
"null",
] as const;
/**
* The meta-schema seed payload: describes the structure of every CAS node.
* This is the root type from which all other type nodes derive.
* Self-describing JSON Schema meta-schema for the supported schema subset.
* Stored as the bootstrap node's payload; its hash equals the node's type field.
*/
const BOOTSTRAP_PAYLOAD = {
description: "json-cas meta-schema seed",
hashAlgorithm: "xxh64",
hashEncoding: "crockford-base32-13",
nodeSchema: {
payload: "any",
timestamp: "number",
type: "Hash",
type: "object",
additionalProperties: false,
description: "json-cas JSON Schema meta-schema",
properties: {
type: {
anyOf: [
{ type: "string", enum: [...JSON_SCHEMA_TYPES] },
{
type: "array",
items: { type: "string", enum: [...JSON_SCHEMA_TYPES] },
},
],
},
properties: {
type: "object",
additionalProperties: { type: "object", additionalProperties: false },
},
required: {
type: "array",
items: { type: "string" },
},
additionalProperties: {
anyOf: [{ type: "boolean" }, { type: "object", additionalProperties: false }],
},
anyOf: {
type: "array",
items: { type: "object", additionalProperties: false },
},
items: { type: "object", additionalProperties: false },
format: { type: "string" },
title: { type: "string" },
enum: { type: "array" },
const: {},
description: { type: "string" },
},
payloadEncoding: "cbor-rfc8949-deterministic",
version: "1",
} as const;
/**
@@ -23,5 +62,8 @@ const BOOTSTRAP_PAYLOAD = {
* Idempotent: calling bootstrap multiple times returns the same hash.
*/
export async function bootstrap(store: Store): Promise<Hash> {
return store.put(null, BOOTSTRAP_PAYLOAD);
if (!isBootstrapCapableStore(store)) {
throw new Error("Store does not support bootstrap");
}
return store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
}
+74 -9
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 } from "./types.js";
import type { CasNode, Store } from "./types.js";
import { verify } from "./verify.js";
// ──────────────────────────────────────────────────────────────────────────────
@@ -97,7 +97,18 @@ describe("createMemoryStore – put and get", () => {
const h1 = await store.put(typeHash, { n: 42 });
const h2 = await store.put(typeHash, { n: 42 });
expect(h1).toBe(h2);
expect(store.list()).toHaveLength(1);
expect(store.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 node = store.get(hash);
expect(node?.type).toBe(typeHash);
expect(node?.type).not.toBe(hash);
});
test("timestamp is preserved on second put (idempotency)", async () => {
@@ -116,9 +127,9 @@ describe("createMemoryStore – put and get", () => {
});
// ──────────────────────────────────────────────────────────────────────────────
// Step 4: store.has() and store.list()
// Step 4: store.has()
// ──────────────────────────────────────────────────────────────────────────────
describe("createMemoryStore – has and list", () => {
describe("createMemoryStore – has", () => {
test("has returns false before put, true after", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "t" });
@@ -129,7 +140,7 @@ describe("createMemoryStore – has and list", () => {
expect(store.has(hash)).toBe(true);
});
test("list returns all stored hashes", async () => {
test("listByType returns all stored hashes for a type", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "t" });
@@ -137,16 +148,58 @@ describe("createMemoryStore – has and list", () => {
const h2 = await store.put(typeHash, { a: 2 });
const h3 = await store.put(typeHash, { a: 3 });
const all = store.list();
const all = store.listByType(typeHash);
expect(all).toHaveLength(3);
expect(all).toContain(h1);
expect(all).toContain(h2);
expect(all).toContain(h3);
});
test("list returns empty array on fresh store", () => {
test("listByType returns empty array on fresh store", () => {
const store = createMemoryStore();
expect(store.list()).toEqual([]);
expect(store.listByType("0000000000000")).toEqual([]);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Step 4b: store.listByType()
// ──────────────────────────────────────────────────────────────────────────────
describe("createMemoryStore – listByType", () => {
test("returns empty array for unknown type", () => {
const store = createMemoryStore();
expect(store.listByType("0000000000000")).toEqual([]);
});
test("returns all hashes for the given type", async () => {
const store = createMemoryStore();
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 byType = store.listByType(typeHash);
expect(byType).toHaveLength(2);
expect(byType).toContain(h1);
expect(byType).toContain(h2);
});
test("idempotent put does not duplicate in listByType", async () => {
const store = createMemoryStore();
const typeHash = await computeSelfHash({ name: "t" });
const h1 = await store.put(typeHash, { n: 1 });
await store.put(typeHash, { n: 1 });
expect(store.listByType(typeHash)).toEqual([h1]);
});
test("bootstrap node is listed under its self type", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
expect(store.listByType(hash)).toEqual([hash]);
});
});
@@ -191,6 +244,18 @@ describe("verify", () => {
// Step 6: bootstrap()
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap", () => {
test("throws when store lacks internal bootstrap path", async () => {
const store: Store = {
put: async () => "0000000000000",
get: () => null,
has: () => false,
listByType: () => [],
};
await expect(bootstrap(store)).rejects.toThrow(
"Store does not support bootstrap",
);
});
test("returns a valid 13-char hash", async () => {
const store = createMemoryStore();
const hash = await bootstrap(store);
@@ -229,6 +294,6 @@ describe("bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toBe(h2);
expect(store.list()).toHaveLength(1);
expect(store.listByType(h1)).toHaveLength(1);
});
});
+10 -1
View File
@@ -1,8 +1,17 @@
export { bootstrap } from "./bootstrap.js";
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
export type { BootstrapCapableStore } from "./bootstrap-capable.js";
export { cborEncode } from "./cbor.js";
export { computeHash, computeSelfHash } from "./hash.js";
export type { JSONSchema } from "./schema.js";
export { getSchema, putSchema, refs, validate, walk } from "./schema.js";
export {
getSchema,
putSchema,
refs,
SchemaValidationError,
validate,
walk,
} from "./schema.js";
export { createMemoryStore } from "./store.js";
export type { CasNode, Hash, Store } from "./types.js";
export { verify } from "./verify.js";
+33
View File
@@ -0,0 +1,33 @@
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
import type { BootstrapCapableStore } from "./bootstrap-capable.js";
import { createMemoryStore } from "./store.js";
import type { CasNode, Hash } from "./types.js";
/** In-memory store wrapper used by schema validation tests. */
export class MemStore implements BootstrapCapableStore {
readonly #inner: BootstrapCapableStore;
constructor() {
this.#inner = createMemoryStore();
}
put(typeHash: Hash, payload: unknown): Promise<Hash> {
return this.#inner.put(typeHash, payload);
}
get(hash: Hash): CasNode | null {
return this.#inner.get(hash);
}
has(hash: Hash): boolean {
return this.#inner.has(hash);
}
listByType(typeHash: Hash): Hash[] {
return this.#inner.listByType(typeHash);
}
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash> {
return this.#inner[BOOTSTRAP_STORE](payload);
}
}
+120
View File
@@ -12,9 +12,121 @@ import type { CasNode, Hash, Store } from "./types.js";
export type JSONSchema = Record<string, unknown>;
export class SchemaValidationError extends Error {
override readonly name = "SchemaValidationError";
constructor(message: string) {
super(message);
}
}
const ajv = new Ajv();
ajv.addFormat("cas_ref", /^[0-9A-HJKMNP-TV-Z]{13}$/);
const ALLOWED_SCHEMA_KEYS = new Set([
"type",
"properties",
"required",
"additionalProperties",
"anyOf",
"items",
"format",
"title",
"enum",
"const",
"description",
]);
const JSON_SCHEMA_TYPES = new Set([
"string",
"number",
"integer",
"boolean",
"object",
"array",
"null",
]);
function isValidTypeValue(type: unknown): boolean {
if (typeof type === "string") {
return JSON_SCHEMA_TYPES.has(type);
}
if (Array.isArray(type)) {
if (type.length === 0) return false;
return type.every(
(entry) => typeof entry === "string" && JSON_SCHEMA_TYPES.has(entry),
);
}
return false;
}
function isValidSchema(value: unknown): boolean {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const schema = value as JSONSchema;
for (const key of Object.keys(schema)) {
if (!ALLOWED_SCHEMA_KEYS.has(key)) return false;
}
if ("type" in schema && !isValidTypeValue(schema.type)) return false;
if ("properties" in schema) {
const properties = schema.properties;
if (
properties === null ||
typeof properties !== "object" ||
Array.isArray(properties)
) {
return false;
}
for (const nested of Object.values(properties as Record<string, unknown>)) {
if (!isValidSchema(nested)) return false;
}
}
if ("required" in schema) {
if (!Array.isArray(schema.required)) return false;
for (const entry of schema.required) {
if (typeof entry !== "string") return false;
}
}
if ("additionalProperties" in schema) {
const additionalProperties = schema.additionalProperties;
if (typeof additionalProperties === "boolean") {
// allowed
} else if (!isValidSchema(additionalProperties)) {
return false;
}
}
if ("anyOf" in schema) {
if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) return false;
for (const entry of schema.anyOf) {
if (!isValidSchema(entry)) return false;
}
}
if ("items" in schema && !isValidSchema(schema.items)) return false;
if ("format" in schema && typeof schema.format !== "string") return false;
if ("title" in schema && typeof schema.title !== "string") return false;
if ("description" in schema && typeof schema.description !== "string") {
return false;
}
if ("enum" in schema) {
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
}
return true;
}
function isMetaSchemaNode(store: Store, node: CasNode): boolean {
const schema = getSchema(store, node.type);
return schema !== null && schema === node.payload;
}
/**
* 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.
@@ -24,6 +136,11 @@ export async function putSchema(
jsonSchema: JSONSchema,
): Promise<Hash> {
const metaHash = await bootstrap(store);
if (!isValidSchema(jsonSchema)) {
throw new SchemaValidationError(
"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema",
);
}
return store.put(metaHash, jsonSchema);
}
@@ -44,6 +161,9 @@ export function getSchema(store: Store, typeHash: Hash): JSONSchema | null {
export function validate(store: Store, node: CasNode): boolean {
const schema = getSchema(store, node.type);
if (schema === null) return false;
if (isMetaSchemaNode(store, node)) {
return isValidSchema(node.payload);
}
return ajv.validate(
schema as Parameters<typeof ajv.validate>[0],
node.payload,
+37 -12
View File
@@ -1,19 +1,39 @@
import {
BOOTSTRAP_STORE,
type BootstrapCapableStore,
} from "./bootstrap-capable.js";
import { computeHash, computeSelfHash } from "./hash.js";
import type { CasNode, Hash, Store } from "./types.js";
import type { CasNode, Hash } from "./types.js";
export function createMemoryStore(): Store {
export function createMemoryStore(): BootstrapCapableStore {
const data = new Map<Hash, CasNode>();
const byType = new Map<Hash, Set<Hash>>();
return {
async put(typeHash: Hash | null, payload: unknown): Promise<Hash> {
const hash =
typeHash === null
? await computeSelfHash(payload)
: await computeHash(typeHash, payload);
function indexHash(type: Hash, hash: Hash): void {
let set = byType.get(type);
if (!set) {
set = new Set();
byType.set(type, set);
}
set.add(hash);
}
async function putSelfReferencing(payload: unknown): Promise<Hash> {
const hash = await computeSelfHash(payload);
if (!data.has(hash)) {
data.set(hash, { type: hash, payload, timestamp: Date.now() });
indexHash(hash, hash);
}
return hash;
}
const store: BootstrapCapableStore = {
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
const hash = await computeHash(typeHash, payload);
if (!data.has(hash)) {
const type = typeHash === null ? hash : typeHash;
data.set(hash, { type, payload, timestamp: Date.now() });
data.set(hash, { type: typeHash, payload, timestamp: Date.now() });
indexHash(typeHash, hash);
}
return hash;
@@ -27,8 +47,13 @@ export function createMemoryStore(): Store {
return data.has(hash);
},
list(): Hash[] {
return [...data.keys()];
listByType(typeHash: Hash): Hash[] {
const set = byType.get(typeHash);
return set ? [...set] : [];
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
return store;
}
+3 -3
View File
@@ -17,11 +17,11 @@ export type CasNode<T = unknown> = {
/**
* Content-addressable store interface.
* put(null, payload) creates a self-referencing (bootstrap) node.
* Self-referencing nodes are created only via bootstrap().
*/
export type Store = {
put(typeHash: Hash | null, payload: unknown): Promise<Hash>;
put(typeHash: Hash, payload: unknown): Promise<Hash>;
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
list(): Hash[];
listByType(typeHash: Hash): Hash[];
};
@@ -0,0 +1,696 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "../src/bootstrap.js";
import {
putSchema,
getSchema,
validate,
refs,
walk,
SchemaValidationError,
} from "../src/schema.js";
import { MemStore } from "../src/mem-store.js";
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 metaHash = await bootstrap(store);
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
expect(typeof metaNode?.payload).toBe("object");
expect(metaNode?.payload).toHaveProperty("type");
});
test("1.2: Meta-schema self-validates", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
expect(validate(store, metaNode as CasNode)).toBe(true);
});
test("1.3: Meta-schema defines all supported keywords", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
const properties = (metaSchema?.properties as Record<string, unknown>) || {};
// Check that all supported keywords are defined
expect(properties).toHaveProperty("type");
expect(properties).toHaveProperty("properties");
expect(properties).toHaveProperty("required");
expect(properties).toHaveProperty("additionalProperties");
expect(properties).toHaveProperty("anyOf");
expect(properties).toHaveProperty("items");
expect(properties).toHaveProperty("format");
expect(properties).toHaveProperty("title");
expect(properties).toHaveProperty("enum");
expect(properties).toHaveProperty("const");
expect(properties).toHaveProperty("description");
});
test("1.4: Meta-schema does not include unsupported keywords", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
const properties = (metaSchema?.properties as Record<string, unknown>) || {};
// Unsupported keywords should not be in properties
expect(properties).not.toHaveProperty("$ref");
expect(properties).not.toHaveProperty("$id");
expect(properties).not.toHaveProperty("$defs");
expect(properties).not.toHaveProperty("allOf");
expect(properties).not.toHaveProperty("oneOf");
expect(properties).not.toHaveProperty("not");
});
test("1.5: Meta-schema node type equals its own hash", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaNode = store.get(metaHash);
expect(metaNode).not.toBeNull();
expect(metaNode?.type).toBe(metaHash);
});
});
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, {});
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" });
expect(hash).toBeTruthy();
});
test("2.3: Accept schema with properties", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "object",
properties: { name: { type: "string" } },
});
expect(hash).toBeTruthy();
});
test("2.4: Accept schema with required fields", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
});
expect(hash).toBeTruthy();
});
test("2.5: Accept schema with additionalProperties = false", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "object",
additionalProperties: false,
});
expect(hash).toBeTruthy();
});
test("2.6: Accept schema with additionalProperties = schema", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "object",
additionalProperties: { type: "string" },
});
expect(hash).toBeTruthy();
});
test("2.7: Accept schema with anyOf", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
anyOf: [{ type: "string" }, { type: "null" }],
});
expect(hash).toBeTruthy();
});
test("2.8: Accept schema with array items", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "array",
items: { type: "number" },
});
expect(hash).toBeTruthy();
});
test("2.9: Accept schema with format constraint", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "string",
format: "cas_ref",
});
expect(hash).toBeTruthy();
});
test("2.10: Accept schema with enum", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "string",
enum: ["red", "green", "blue"],
});
expect(hash).toBeTruthy();
});
test("2.11: Accept schema with const", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await 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, {
type: "string",
title: "User Name",
description: "The user's full name",
});
expect(hash).toBeTruthy();
});
test("2.13: Accept complex nested schema", async () => {
const store = new MemStore();
await bootstrap(store);
const hash = await putSchema(store, {
type: "object",
required: ["type", "payload"],
properties: {
type: { type: "string", format: "cas_ref" },
payload: {
anyOf: [{ type: "object" }, { type: "null" }],
},
refs: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
});
expect(hash).toBeTruthy();
});
});
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();
});
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 any)).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 any)
).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 any)
).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 any)
).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 any)
).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 any)
).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();
});
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 any)
).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 any)
).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 any)
).toThrow();
});
test("3.12: Reject schema with empty enum array", async () => {
const store = new MemStore();
await bootstrap(store);
expect(
async () => await putSchema(store, { type: "string", enum: [] })
).toThrow();
});
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 any)
).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 any)
).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 any)
).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 any)
).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 any)
).toThrow();
});
});
describe("Test Suite 4: Error Messages and Debugging", () => {
test("4.1: Error includes schema validation details", async () => {
const store = new MemStore();
await bootstrap(store);
try {
await putSchema(store, { type: 123 } as any);
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeInstanceOf(SchemaValidationError);
expect((error as Error).message).toContain("Invalid schema");
}
});
test("4.2: Error distinguishes schema validation from data validation", async () => {
const store = new MemStore();
await bootstrap(store);
try {
await putSchema(store, { type: "invalid-type" } as any);
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeInstanceOf(SchemaValidationError);
expect((error as Error).message.toLowerCase()).toContain("schema");
}
});
});
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 newMetaHash = await bootstrap(store);
// The new hash should be different from the old system metadata hash
// We just verify it's a valid hash format
expect(newMetaHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
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);
// This is the kind of schema that existed before
const schemaHash = await putSchema(store, {
type: "object",
properties: {
name: { type: "string" },
},
});
expect(schemaHash).toBeTruthy();
});
test("5.3: Data nodes with valid schemas still validate", async () => {
const store = new MemStore();
await bootstrap(store);
const schemaHash = await putSchema(store, {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
},
});
const dataNode = store.get(
await store.put(schemaHash, { name: "test" })
);
expect(dataNode).not.toBeNull();
expect(validate(store, dataNode as CasNode)).toBe(true);
});
test("5.4: Invalid data still fails validation", async () => {
const store = new MemStore();
await bootstrap(store);
const schemaHash = await putSchema(store, {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
},
});
const dataNode = store.get(
await store.put(schemaHash, { name: 123 }) // wrong type
);
expect(dataNode).not.toBeNull();
expect(validate(store, dataNode as CasNode)).toBe(false);
});
});
describe("Test Suite 6: Integration with Existing Functionality", () => {
test("6.1: getSchema works with validated schemas", async () => {
const store = new MemStore();
await bootstrap(store);
const originalSchema = { type: "string", title: "Test" };
const schemaHash = await putSchema(store, originalSchema);
const retrieved = getSchema(store, schemaHash);
expect(retrieved).toEqual(originalSchema);
});
test("6.2: validate() works with schemas validated by meta-schema", async () => {
const store = new MemStore();
await bootstrap(store);
const schemaHash = await putSchema(store, { type: "number" });
const validNode = store.get(await store.put(schemaHash, 42));
const invalidNode = store.get(await store.put(schemaHash, "not a number"));
expect(validate(store, validNode as CasNode)).toBe(true);
expect(validate(store, invalidNode as CasNode)).toBe(false);
});
test("6.3: refs() works with validated schemas containing cas_ref", async () => {
const store = new MemStore();
await bootstrap(store);
const schemaHash = await putSchema(store, {
type: "object",
properties: {
ref: { type: "string", format: "cas_ref" },
},
});
const refHash = "0000000000001";
const dataNode = store.get(
await store.put(schemaHash, { ref: refHash })
);
const extractedRefs = refs(store, dataNode as CasNode);
expect(extractedRefs).toContain(refHash);
});
test("6.4: walk() works with graphs using validated schemas", async () => {
const store = new MemStore();
await bootstrap(store);
const schemaHash = await putSchema(store, {
type: "object",
properties: {
next: {
anyOf: [
{ type: "string", format: "cas_ref" },
{ type: "null" },
],
},
},
});
const node2Hash = await store.put(schemaHash, { next: null });
const node1Hash = await store.put(schemaHash, { next: node2Hash });
const visited: string[] = [];
walk(store, node1Hash, (hash) => visited.push(hash));
expect(visited).toContain(node1Hash);
expect(visited).toContain(node2Hash);
});
test("6.5: Idempotency preserved for putSchema", async () => {
const store = new MemStore();
await bootstrap(store);
const schema = { type: "string", title: "Test" };
const hash1 = await putSchema(store, schema);
const hash2 = await putSchema(store, schema);
expect(hash1).toBe(hash2);
});
});
describe("Test Suite 7: Meta-Schema Content Validation", () => {
test("7.1: Meta-schema allows recursive schema definitions", async () => {
const store = new MemStore();
const metaHash = await bootstrap(store);
const metaSchema = getSchema(store, metaHash);
expect(metaSchema).not.toBeNull();
// The meta-schema should have properties that can contain schemas
const properties = (metaSchema?.properties as Record<string, unknown>) || {};
expect(properties).toHaveProperty("properties");
});
test("7.2: Meta-schema restricts additionalProperties", async () => {
const store = new MemStore();
await bootstrap(store);
// Schema with unknown keyword should be rejected if meta-schema is strict
try {
await putSchema(store, {
type: "string",
unknownKeyword: "value",
} as any);
// If we get here, meta-schema allows additional properties
// This is acceptable behavior
} catch (error) {
// If it throws, meta-schema is strict about additionalProperties
expect(error).toBeInstanceOf(SchemaValidationError);
}
});
test("7.3: Meta-schema validates type as string OR array", async () => {
const store = new MemStore();
await bootstrap(store);
// Single string type
const hash1 = await putSchema(store, { type: "string" });
expect(hash1).toBeTruthy();
// Array of types
const hash2 = await putSchema(store, { type: ["string", "null"] } as any);
expect(hash2).toBeTruthy();
// Invalid type (number)
expect(
async () => await putSchema(store, { type: 123 } as any)
).toThrow();
});
});
describe("Test Suite 8: Performance and Edge Cases", () => {
test("8.1: Validation performance is acceptable", async () => {
const store = new MemStore();
await bootstrap(store);
const complexSchema = {
type: "object",
properties: {
level1: {
type: "object",
properties: {
level2: {
type: "object",
properties: {
level3: { type: "string" },
},
},
},
},
},
};
const start = performance.now();
for (let i = 0; i < 100; i++) {
await putSchema(store, complexSchema);
}
const duration = performance.now() - start;
// Should complete in reasonable time (< 100ms for 100 validations)
expect(duration).toBeLessThan(1000);
});
test("8.2: Large schemas are handled correctly", async () => {
const store = new MemStore();
await bootstrap(store);
const largeSchema: Record<string, unknown> = {
type: "object",
properties: {},
};
// Create a schema with 100 properties
const props = largeSchema.properties as Record<string, unknown>;
for (let i = 0; i < 100; i++) {
props[`prop${i}`] = { type: "string" };
}
const hash = await putSchema(store, largeSchema);
expect(hash).toBeTruthy();
});
test("8.3: Deeply nested schemas validate correctly", async () => {
const store = new MemStore();
await bootstrap(store);
// Build a 5-level deep schema
let schema: Record<string, unknown> = { type: "string" };
for (let i = 0; i < 5; i++) {
schema = {
type: "object",
properties: { nested: schema },
};
}
const hash = await 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);
// Schema where additionalProperties has same structure as parent
const schema = {
type: "object",
properties: {
value: { type: "string" },
},
additionalProperties: {
type: "object",
properties: {
value: { type: "string" },
},
},
};
const hash = await putSchema(store, schema);
expect(hash).toBeTruthy();
});
});
+2 -1
View File
@@ -4,5 +4,6 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
File diff suppressed because one or more lines are too long