Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7aa90d8e6 | |||
| 9a1954f6f9 | |||
| b062fcbc44 | |||
| 0706307e85 | |||
| d57a454b78 | |||
| 34847cae59 | |||
| 054d78296a | |||
| 8f54dcfa7c | |||
| 7828dd1c41 | |||
| a30af6efb5 | |||
| 712f930072 | |||
| cfe791180b | |||
| 09526b63da | |||
| 00f191e105 | |||
| 52cb7a30ba | |||
| 1b53cf5ff8 | |||
| 17ed619900 | |||
| bad62a82a9 | |||
| 4989cd31ba | |||
| 38aad696fc | |||
| fb81f5a429 | |||
| 5fc475704b | |||
| 6e77e4a110 | |||
| a3a21b153c |
@@ -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,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.d.ts.map
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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:^",
|
||||
},
|
||||
|
||||
@@ -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 ."
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/cli-json-cas",
|
||||
"version": "0.1.1",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"json-cas": "./src/index.ts"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import {
|
||||
bootstrap,
|
||||
@@ -53,7 +53,8 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||
const { flags, positional } = parseArgs(process.argv.slice(2));
|
||||
|
||||
const defaultStorePath = join(homedir(), ".uncaged", "json-cas");
|
||||
const storePath = typeof flags.store === "string" ? flags.store : defaultStorePath;
|
||||
const storePath =
|
||||
typeof flags.store === "string" ? flags.store : defaultStorePath;
|
||||
const compact = flags.json === true;
|
||||
|
||||
// ---- Helpers ----
|
||||
@@ -116,10 +117,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,13 +177,6 @@ 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];
|
||||
if (!hash) die("Usage: json-cas refs <hash>");
|
||||
@@ -271,7 +265,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 +330,6 @@ switch (cmd) {
|
||||
await cmdVerify(rest);
|
||||
break;
|
||||
|
||||
case "list":
|
||||
await cmdList();
|
||||
break;
|
||||
|
||||
case "refs":
|
||||
await cmdRefs(rest);
|
||||
break;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -1,11 +1,19 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
@@ -6,11 +8,18 @@ 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 +40,112 @@ 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;
|
||||
}
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"references": [{ "path": "../json-cas" }]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,11 +1,19 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"references": [{ "path": "../json-cas" }]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,11 +1,19 @@
|
||||
{
|
||||
"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"
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,62 @@
|
||||
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 +65,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
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 { 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";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { CasNode, Hash } from "./types.js";
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import * as AjvModule from "ajv";
|
||||
// but tsc with verbatimModuleSyntax sees the namespace wrapper.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: CJS interop
|
||||
const Ajv = ((AjvModule as any).default ?? AjvModule) as {
|
||||
new (): { addFormat(name: string, re: RegExp): void; validate(schema: unknown, data: unknown): boolean };
|
||||
new (): {
|
||||
addFormat(name: string, re: RegExp): void;
|
||||
validate(schema: unknown, data: unknown): boolean;
|
||||
};
|
||||
};
|
||||
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
@@ -12,9 +15,117 @@ import type { CasNode, Hash, Store } from "./types.js";
|
||||
|
||||
export type JSONSchema = Record<string, unknown>;
|
||||
|
||||
export class SchemaValidationError extends Error {
|
||||
override readonly name = "SchemaValidationError";
|
||||
}
|
||||
|
||||
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 +135,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 +160,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,716 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "../src/bootstrap.js";
|
||||
import { MemStore } from "../src/mem-store.js";
|
||||
import type { JSONSchema } from "../src/schema.js";
|
||||
import {
|
||||
getSchema,
|
||||
putSchema,
|
||||
refs,
|
||||
SchemaValidationError,
|
||||
validate,
|
||||
walk,
|
||||
} from "../src/schema.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 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),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.4: Reject schema with required not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
required: "name",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.5: Reject schema with required containing non-strings", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name", 123, true],
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.6: Reject schema with additionalProperties as string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: "yes",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.7: Reject schema with anyOf not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
anyOf: { type: "string" },
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
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 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),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.11: Reject schema with enum not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
enum: "red",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
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 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),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.15: Reject schema with unsupported $ref keyword", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
$ref: "#/definitions/user",
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.16: Reject completely invalid data (non-object)", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, "not-a-schema" as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.17: Reject nested invalid schema in properties", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "invalid-type" },
|
||||
},
|
||||
} as unknown as JSONSchema),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
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 unknown as JSONSchema);
|
||||
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 unknown as JSONSchema);
|
||||
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 unknown as JSONSchema);
|
||||
// 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 unknown as JSONSchema);
|
||||
expect(hash2).toBeTruthy();
|
||||
|
||||
// Invalid type (number)
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: 123 } as unknown as JSONSchema),
|
||||
).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();
|
||||
});
|
||||
});
|
||||
@@ -4,5 +4,6 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
"noImplicitOverride": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@uncaged/json-cas": ["./packages/json-cas/src/index.ts"],
|
||||
"@uncaged/json-cas-fs": ["./packages/json-cas-fs/src/index.ts"],
|
||||
"@uncaged/json-cas-workflow": [
|
||||
"./packages/json-cas-workflow/src/index.ts"
|
||||
]
|
||||
},
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
Reference in New Issue
Block a user