84 Commits

Author SHA1 Message Date
xiaoju 5d871a1357 Merge pull request 'chore: bump @ocas/core to 0.4.1' (#96) from chore/bump-core-0.4.1 into main
CI / check (push) Successful in 6m16s
2026-06-07 15:46:31 +00:00
xiaoju 01cf04fb3a docs: rewrite changelogs — per-package scope, add dates, fix 0.4.1 entry
CI / check (pull_request) Successful in 2m52s
2026-06-07 15:34:13 +00:00
xiaoju 1531255698 chore: bump @ocas/core to 0.4.1
CI / check (pull_request) Successful in 2m38s
2026-06-07 15:20:19 +00:00
xingyue 1f5eb7883c Merge pull request 'chore: remove legacy-packages/' (#95) from chore/remove-legacy-packages into main
CI / check (push) Successful in 2m51s
chore: remove legacy-packages/ (#95)
2026-06-07 15:13:29 +00:00
xiaomo db4072396b chore: remove legacy-packages/ and docs/ — outdated content
CI / check (pull_request) Successful in 5m47s
- legacy-packages/e2e-check.yaml: old CLI name, bun, wrong repo path
- docs/sync-readme.md: generic README convention, not needed
- Remove stale CLAUDE.md reference to docs/
2026-06-07 15:00:44 +00:00
xingyue 40dacee1be Merge pull request 'fix: gc must traverse oneOf and preserve template content' (#94) from fix/93-gc-collectrefs-oneof into main
CI / check (push) Successful in 2m38s
2026-06-07 13:36:08 +00:00
xiaoju 4659258693 fix: gc must traverse oneOf and preserve template content
CI / check (pull_request) Successful in 3m46s
collectRefs silently skipped oneOf even though it is in the meta-schema's
allowed keys. uwf step nodes use the standard JSON-Schema idiom
oneOf: [{type:"null"}, {type:"string", format:"ocas_ref"}] for nullable
prev/detail/start refs, so walk() never reached the chain and gc swept
the intermediate steps as false orphans. Mirror the anyOf branch in
collectRefs so every oneOf variant contributes refs.

Also align gc with closure.ts Phase 3: walk @ocas/template/text/<schema>
content for every reachable schema so rendered template nodes survive
when their schema is reachable, and are still collected when the schema
itself is unreachable.

Fixes #93
2026-06-07 13:06:59 +00:00
xiaomo f5eef854a3 Merge pull request 'chore: release @ocas/core@0.4.0, @ocas/fs@0.4.0, @ocas/cli@0.4.0' (#92) from release/v0.4.0 into main
CI / check (push) Successful in 4m5s
2026-06-07 06:10:55 +00:00
xiaoju 741fea9e51 release: v0.4.0
CI / check (pull_request) Successful in 2m20s
2026-06-07 13:25:45 +08:00
xingyue 522b782571 chore: release @ocas/core@0.4.0, @ocas/fs@0.4.0, @ocas/cli@0.4.0 2026-06-07 13:25:10 +08:00
xiaomo 949663545f Merge pull request 'docs: add changeset skip-rebuild rule to CLAUDE.md' (#90) from chore/claude-md-changeset-rule into main
CI / check (push) Successful in 3m48s
Merge PR #90: docs: add changeset skip-rebuild rule to CLAUDE.md
2026-06-07 03:57:32 +00:00
xiaomo 544226041e Merge pull request 'feat: add ocas export / import — CAS closure bundling' (#91) from fix/83-export-import into main
CI / check (push) Successful in 3m10s
Merge PR #91: feat: add ocas export / import — CAS closure bundling (#83)
2026-06-07 03:48:37 +00:00
xiaoju dbfaf01031 docs: add export/import to README/cards; tidy bundle.ts imports
CI / check (pull_request) Successful in 2m35s
Address reviewer feedback on #83:

- Replace dynamic `await import("./bootstrap-capable.js")` in
  bundle.ts with a static top-of-file import (no real cycle exists,
  bootstrap.ts is already statically imported).
- Remove unused `bootstrapSym` Symbol.for() / `void bootstrapSym`
  dead code in importBundle.
- Move the misplaced `import { decode } from "cborg"` to the top of
  bundle.ts with the other imports.
- Document the new `export` / `import` commands and `--store`,
  `--scope`, `-o` flags in:
  - root README.md (commands + global flags)
  - packages/cli/README.md (command table + bundles section + global flags)
  - packages/cli/prompts/usage.md (`ocas prompt usage` output)
  - packages/core/README.md (Closure & Bundles API surface)
  - .cards/cli.md (bundle commands + --store architecture note)
2026-06-07 03:19:59 +00:00
xiaoju 4ba3a00de9 feat: add CAS closure export/import bundles
Implements `ocas export` / `ocas import` for shipping a self-contained
closure of CAS nodes, variables and tags between stores, plus a
read-only `--store <bundle.tar>` flag for inspecting bundles without
extracting them.

- core: computeClosure walks refs + schema chains and gathers vars/tags
- core: exportBundle / importBundle / loadBundleStore use a custom
  POSIX/ustar tar (no external deps); content-addressed dedup on import,
  optional --scope remap of non-@ocas variable names
- core: new @ocas/output/export and @ocas/output/import builtin schemas
- cli: new export and import commands, --store read-only mode, write
  commands rejected with a clear error when --store is set

Closes #83
2026-06-07 01:13:36 +00:00
xiaoju b2430172a2 docs: add changeset skip-rebuild rule to CLAUDE.md
CI / check (pull_request) Successful in 1m47s
No need to re-run build/test after adding a changeset markdown file.

小橘 🍊(NEKO Team)
2026-06-07 00:32:10 +00:00
xiaomo dd5cb49168 Merge pull request 'perf: implement lazy loading in FsStore (#85)' (#89) from fix/85-fsstore-lazy-loading into main
CI / check (push) Successful in 1m46s
Merge PR #89: perf: implement lazy loading in FsStore (#85)
2026-06-07 00:31:31 +00:00
xiaoju 48c099ba03 perf: implement lazy loading in FsStore
CI / check (pull_request) Successful in 1m40s
FsStore previously CBOR-decoded all .bin nodes into memory at startup,
making cold-open O(n) in time and memory. Now it scans only filenames
into a Set<Hash> at init and reads/decodes nodes from disk on first
get(). has() and listAll() use the filename set; put() write-throughs
to cache; delete() clears cache and disk. Index/meta migration still
performs a one-time scan when _index/ is missing.

Adds 12 new tests (L1-L12) covering startup-no-decode, lazy get,
filename-based has/listAll, write-through put, delete cleanup, list
operations, and migration/bootstrap regression. All existing tests
pass unchanged.

Fixes #85
2026-06-07 00:25:39 +00:00
xiaomo 22ec210813 Merge pull request 'docs: add efficiency guidelines to CLAUDE.md' (#88) from chore/claude-md-efficiency into main
CI / check (push) Successful in 1m42s
Merge PR #88: docs: add efficiency guidelines to CLAUDE.md
2026-06-06 23:51:12 +00:00
xiaoju 578973fd62 docs: add efficiency guidelines to CLAUDE.md
CI / check (pull_request) Successful in 1m44s
Reduce Claude Code agent overhead: skip malware comments on trusted code,
stop re-verifying after tests pass.

小橘 🍊(NEKO Team)
2026-06-06 23:47:12 +00:00
xiaomo 3e93794c59 Merge pull request 'fix: move CAS node files into nodes/ subdirectory (#84)' (#87) from fix/84-nodes-subdirectory into main
CI / check (push) Successful in 1m37s
Merge PR #87: fix: move CAS node files into nodes/ subdirectory (#84)
2026-06-06 23:46:07 +00:00
xiaoju 3b089c2291 fix: move CAS node files into nodes/ subdirectory
CI / check (pull_request) Successful in 1m45s
Restructure the FsStore on-disk layout so that all `<HASH>.bin` node
files live under a `nodes/` subdirectory of the store root, instead of
being interleaved with metadata directories (`_index/`, `_store.db`,
`_meta`) at the top level.

Pre-existing flat-layout stores are auto-migrated on first open: any
`.bin` files found in the store root are renamed (not copied) into
`nodes/`. Migration is idempotent and safe to re-run.

Fixes #84
2026-06-06 23:37:19 +00:00
xiaoju 5414332b7f Merge pull request 'chore: sync solve-issue workflow from uwf canonical version' (#86) from chore/sync-solve-issue-workflow into main
CI / check (push) Successful in 1m48s
2026-06-06 22:45:08 +00:00
xiaoju 146e7f4b69 chore: add changeset + doc update requirements to developer/reviewer
CI / check (pull_request) Successful in 1m49s
Developer: steps 12-13 — add changeset with correct bump type, update docs
Reviewer: checks 6-7 — verify changeset exists, docs updated for user-facing changes

小橘 🍊
2026-06-06 22:43:01 +00:00
xiaoju 071864c339 chore: sync solve-issue workflow from uwf canonical version
CI / check (pull_request) Successful in 1m46s
- Mode A/B/C logic for fresh issue / existing PR / tester bounce-back
- bun→pnpm references
- repoRemote frontmatter field
- Simplified oneOf schema (no redundant 'type: object')

小橘 🍊
2026-06-06 22:35:44 +00:00
xingyue 24bd04b884 Merge pull request 'chore: add changeset for prompt bootstrap rename' (#82) from chore/changeset-bootstrap into main
CI / check (push) Successful in 1m42s
2026-06-06 21:58:26 +00:00
xiaomo 7ae13289a9 chore: add changeset for prompt setup→bootstrap rename
CI / check (pull_request) Successful in 1m47s
2026-06-06 21:43:26 +00:00
xiaoju 0e71f4d88d Merge pull request 'chore: rename prompt setup→bootstrap, programmatic generation, bun→pnpm cleanup' (#81) from chore/80-bootstrap-cleanup into main
CI / check (push) Successful in 3m7s
2026-06-06 14:34:37 +00:00
xiaoju 7871db748f feat: add 'ocas prompt list' subcommand
CI / check (pull_request) Successful in 4m55s
Implements the missing 'list' case referenced in bootstrap output.

小橘 🍊
2026-06-06 14:29:22 +00:00
xiaoju e3c84c5794 chore: rename prompt setup→bootstrap, programmatic generation, bun→pnpm cleanup
CI / check (pull_request) Successful in 2m59s
Part 1: Rename 'ocas prompt setup' → 'ocas prompt bootstrap' in CLI
Part 2: Replace static setup.md with cmdPromptBootstrap() function
  - CLI_VERSION injected dynamically (same pattern as uwf)
  - Covers fresh install + upgrade scenarios
  - Includes preflight checks, skill install, e2e verification
Part 3: Clean up bun references in workflow YAML files
  - .workflows/retrospect-workflow.yaml: bun→pnpm
  - .workflows/solve-issue.yaml: bun→pnpm
  - .workflows/e2e-check.yaml: archived to legacy-packages/

Fixes #80
小橘 🍊
2026-06-06 14:03:24 +00:00
xiaomo 693feb19e2 Merge pull request 'chore: update workflow $START from _ to new/resume' (#79) from chore/uwf-start-new-resume into main
CI / check (push) Successful in 2m3s
2026-06-05 10:09:05 +00:00
xiaoju 050fc8eee4 chore: update workflow $START from _ to new/resume
CI / check (pull_request) Successful in 5m22s
Align with united-workforce#101 — $START now requires explicit
'new' and 'resume' status keys instead of magic '_'.

Refs shazhou/united-workforce#101
2026-06-05 09:57:24 +00:00
xiaoju 7c145597d5 Merge pull request 'fix: add allowBuilds for pnpm 11 CI' (#78) from fix/ci-allow-builds into main
CI / check (push) Successful in 1m50s
2026-06-04 10:46:07 +00:00
xiaoju 73db7b9cac fix: add allowBuilds for pnpm 11 CI
CI / check (pull_request) Successful in 2m10s
2026-06-04 10:39:12 +00:00
xiaoju 02a1c18ac5 Merge pull request 'fix: use corepack for pnpm in CI' (#77) from fix/ci-pnpm-setup into main
CI / check (push) Failing after 1m50s
2026-06-04 10:32:59 +00:00
xiaoju 1bd4edbdd1 fix: disable pnpm minimumReleaseAge in CI
CI / check (pull_request) Failing after 1m21s
pnpm 11 defaults minimumReleaseAge to 1440 min. Set to 0 in
pnpm-workspace.yaml for our own packages.
2026-06-04 10:13:46 +00:00
xiaoju 49f096b18f fix: use corepack for pnpm in CI
CI / check (pull_request) Failing after 32s
Drop pnpm/action-setup (requires explicit version), use corepack enable instead.
Also align Node to 22 (consistent with uwf).

Closes #76
2026-06-04 10:07:28 +00:00
xiaomo 0dfc26fcfe Merge pull request 'chore: add Gitea CI workflow' (#76) from chore/add-ci into main
CI / check (push) Failing after 16s
chore: add Gitea CI workflow (#76)
2026-06-04 09:38:05 +00:00
xiaoju 6b9ebd1796 chore: add Gitea CI workflow (build + lint + test)
CI / check (pull_request) Failing after 1m18s
小橘 🍊(NEKO Team)
2026-06-04 09:36:51 +00:00
xiaomo b3879c583a Merge pull request 'chore: use proman test instead of vitest run' (#75) from chore/proman-test-script into main
chore: use proman test instead of vitest run (#75)
2026-06-04 01:57:23 +00:00
xingyue 01d4f0fa14 chore: use proman test instead of vitest run 2026-06-04 09:51:30 +08:00
xiaomo d79d0227fa Merge pull request 'fix: resolve prompt files from package root instead of dist' (#74) from fix/prompt-path into main
fix: resolve prompt files from package root, suppress ExperimentalWarning in tests (#74)
2026-06-04 00:38:14 +00:00
tuanzi e4e4ce0f73 fix: suppress SQLite ExperimentalWarning in edge-cases and schema-validation tests
The local runCli helpers in edge-cases.test.ts (Phase 3/4/7) and raw
execFileSync calls in schema-validation.test.ts (tests 2.1, 2.3) were
missing NODE_NO_WARNINGS=1, causing ExperimentalWarning to leak into
stderr snapshots. Added env override to match what helpers.runCli
already does.
2026-06-04 00:36:38 +00:00
tuanzi 13b12ef50c fix: resolve prompt files from package root instead of dist
prompts/*.md files are not copied to dist/ during tsc build,
causing `ocas prompt setup/usage` to fail with ENOENT.

- Change join(__dirname, "prompts") → join(__dirname, "..", "prompts")
- Add prompts/ to package.json "files" for npm publishing
- Update snapshots (Node.js SQLite ExperimentalWarning + version string)
2026-06-04 00:11:28 +00:00
xiaomo a9d43abf28 release: @ocas/cli@0.3.1 2026-06-04 00:01:29 +00:00
xiaomo 6f054f6447 fix(cli): update prompts — bun→pnpm, remove stale --var-db flag 2026-06-03 23:49:11 +00:00
xiaomo 5e9b266ebd docs: add pkg descriptions/keywords/engines, fix core README
Fixes #70, #71, #72, #73
2026-06-03 23:23:48 +00:00
xiaomo c4d9205eb2 docs: update all documentation for node:sqlite, pnpm, proman workflow
- README: bun → pnpm, update API examples, add Node >=22.5.0 requirement
- CLAUDE.md: replace 3-phase release process with proman bump/publish
- Package READMEs: fix package names, update storage/API descriptions
- Cards: update store.md (sqlite), cli.md (db filename, remove --var-db)
- docs/sync-readme.md: update to proman workflow
2026-06-03 23:21:30 +00:00
xiaoju 9d17be8b7b release: v0.3.0 2026-06-03 23:06:00 +00:00
xiaomo 7e9bd26fec fix: suppress ExperimentalWarning in tests via NODE_NO_WARNINGS env
- Use NODE_NO_WARNINGS=1 in execFileSync env instead of --no-warnings flag
- Remove overly broad process.removeAllListeners('warning') from CLI entry
- Add engines field requiring Node >=22.5.0 (node:sqlite availability)
- Update proman to 0.4.2
2026-06-03 23:02:43 +00:00
xiaomo 3168bf55c3 chore: add changeset for node:sqlite migration 2026-06-03 22:30:15 +00:00
xiaomo 00a536631a refactor: migrate from better-sqlite3 to node:sqlite
- Replace better-sqlite3 with built-in node:sqlite (DatabaseSync)
- Add transaction() helper for manual BEGIN/COMMIT/ROLLBACK
- Suppress ExperimentalWarning in CLI tests with --no-warnings
- Remove better-sqlite3 from dependencies and onlyBuiltDependencies
- No more native addon compilation issues across Node versions
2026-06-03 22:11:44 +00:00
xiaomo f8103e20ce chore: bump @shazhou/proman to 0.4.0, remove link override 2026-06-03 16:30:24 +00:00
xiaomo 5c567dc455 Revert "Merge pull request 'chore: remove redundant vite/vitest devDeps' (#69) from chore/remove-redundant-devdeps into main"
This reverts commit 08a2bddcf0, reversing
changes made to 36ebf42f2f.
2026-06-03 15:15:38 +00:00
xiaomo 08a2bddcf0 Merge pull request 'chore: remove redundant vite/vitest devDeps' (#69) from chore/remove-redundant-devdeps into main 2026-06-03 14:28:35 +00:00
xiaomo 6fc3b9030b chore: remove redundant vite/vitest devDeps (proman bundles them) 2026-06-03 14:25:35 +00:00
xiaoju 36ebf42f2f chore: bump 0.2.2, fix lint, biome format
- Bump all packages to 0.2.2
- Fix noNonNullAssertion in cli/index.ts
- Fix unused imports in fs/sqlite-store.ts
- Biome 2.4.16 migration + format all files
- Update snapshots for version change
2026-06-03 11:16:16 +00:00
xiaoju f286df91f0 chore: restore workspace:* 2026-06-03 09:43:57 +00:00
xiaoju 8971206a3e Merge release/0.2.1 into main 2026-06-03 09:43:37 +00:00
xiaoju 807c0942e8 release: v0.2.1
- Clean dist: no stale variable-store.js (bun:sqlite reference removed)
- Fix vitest rejects pattern in liquid-render tests
- proman.yaml runtime: bun → node
- Upgrade @shazhou/proman to 0.2.2
2026-06-03 09:43:27 +00:00
xiaoju 5c0670e69b release: prepare v0.2.1 2026-06-03 09:40:07 +00:00
xiaoju c90efda4dd chore: upgrade @shazhou/proman to 0.2.2 2026-06-03 09:39:59 +00:00
xiaoju 2d4f76330c fix: vitest rejects pattern + runtime config
- Fix 4 liquid-render tests: expect(async()=>{}).rejects → expect(promise).rejects
- proman.yaml: runtime bun → node (matches actual stack)
- package.json: test script uses vitest directly (avoid proman recursion)
- Add changeset for 0.2.1 patch (clean dist)
2026-06-03 09:30:23 +00:00
xiaomo 768ae20a24 Merge pull request 'feat: migrate var/tag store from JSONL to better-sqlite3' (#68) from feat/60-sqlite-var-tag into main
feat: migrate var/tag store from JSONL to better-sqlite3 (#68)
2026-06-03 08:40:45 +00:00
xiaoju 72ba313d12 fix: address review — transactions, LIKE escape, post-filter pagination
Critical fixes:
1. LIKE ESCAPE '\' clause added for namePrefix queries
2. list() limit/offset now applied AFTER tag/label post-filter
3. tag()/untag() wrapped in db.transaction()

Warning fixes:
4. set()/update() wrapped in db.transaction() (txnSetVar)
5. listByTag sort/limit/offset pushed to SQL
6. close() idempotent (double-close guard)
7. JSONL → SQLite migration on first open (vars + tags)
8. History truncation simplified (NOT IN subquery)
9. Removed unnecessary pre-fetch in upsert (ON CONFLICT handles it)

Nits:
10. Database.Database type instead of InstanceType
11. row.name instead of row["name"]
12. DESC index on var_history position
13. key+value composite index on tags
2026-06-03 08:38:44 +00:00
xiaoju 1fe6035be5 feat: migrate var/tag store from JSONL to better-sqlite3
- New sqlite-store.ts: VarStore + TagStore backed by SQLite (WAL mode)
- Tables: vars (composite PK name+schema), var_history (with MAX_HISTORY truncation), tags (composite PK target+key)
- Indexed queries for name, created, updated, tag key
- Tags/labels stored as JSON columns with post-filter
- Removed var-store.ts (JSONL append-log implementation)
- Updated tests: JSONL assertions → SQLite db file checks

Closes #60
2026-06-03 08:29:22 +00:00
xiaoju e3f7ec1a11 Merge pull request 'chore: 去掉 Bun,切换到 pnpm + 纯 Node runtime' (#67) from chore/66-remove-bun into main 2026-06-03 07:55:03 +00:00
xiaoju 9ac08e5893 chore: 去掉 Bun,切换到 pnpm + 纯 Node runtime
- bun.lock → pnpm-lock.yaml + pnpm-workspace.yaml
- 删除 sqlite-adapter.ts(Bun/Node 双 runtime 兼容层,未被使用)
- package.json 删除 workspaces 字段,加 pnpm.onlyBuiltDependencies
- 加 vite 8 显式依赖(vitest 4.x peer dep)
- CLAUDE.md 全面更新:Runtime/Commands/Release 流程
- .gitignore 加 bun.lock

36/36 files pass, 617/617 tests pass

Fixes #66
2026-06-03 07:43:09 +00:00
xiaoju fe56634160 fix: clean build 兼容性修复
- 根 package.json 加 @types/node(clean install 后 tsc 需要)
- CLI tests entrypoint 从 src/index.ts 改为 dist/index.js
- 局部 runCli 支持 rest 和 array 两种调用模式(rawArgs.flat())
- 所有剩余 tsx 引用改为 node

36/36 files pass, 617/617 tests pass

Fixes #64
2026-06-03 06:32:39 +00:00
xiaoju dd75c1f39d Merge pull request 'fix: 修复 vitest 迁移后测试失败' (#65) from fix/64-test-failures into main 2026-06-03 06:16:56 +00:00
xiaoju 32b520b2a4 fix: 修复 vitest 迁移后测试失败
- async 函数断言改用 await expect().rejects.toThrow()
- 所有局部 runCli 从 tsx 改为 node
- rest params (...args) 改为 array params (args)
- 更新 snapshots

36/36 files pass, 617/617 tests pass

Fixes #64
2026-06-03 06:16:33 +00:00
xiaoju 05f3246e5b Merge pull request 'chore: 测试框架从 bun:test 迁移到 vitest' (#63) from chore/62-vitest into main 2026-06-03 04:16:29 +00:00
xiaoju 736d7e7374 chore: 测试框架从 bun:test 迁移到 vitest
- 36 个 test 文件 bun:test → vitest
- Bun.spawn() → execFileSync('tsx', ...)
- Bun.file() → readFileSync
- import.meta.dir → import.meta.dirname (tests) / __dirname (CLI source)
- 删除 bun-types devDep
- 添加 vitest + tsx devDep
- CLI shebang bun → node
- 30/36 test files pass, 558/617 tests pass

Refs #62
2026-06-03 04:11:10 +00:00
xiaomo 3cfcf6db89 Merge pull request 'feat: cli 包完整 build 支持(tsc emit + Node 兼容)' (#61) from feat/58-cli-build into main 2026-06-03 03:28:31 +00:00
xiaoju 5f562cbc5a feat: cli 包完整 build 支持(tsc emit + Node 兼容)
- tsconfig: 启用 emit (rootDir/outDir/composite), 添加 project references
- shebang: bun → node
- bin: src/index.ts → dist/index.js
- import.meta.dir → import.meta.dirname (去掉 Bun 专有 API)
- prompts 目录移到包根, src/dist 路径统一
- 修复 exactOptionalPropertyTypes 类型错误

Fixes #58
2026-06-03 02:29:01 +00:00
xiaoju 234bd0d611 chore: remove legacy release scripts (replaced by proman)
小橘 🍊
2026-06-03 00:37:15 +00:00
xiaoju ff4c4f3eff chore: remove per-package scripts (test/prepublishOnly)
All dev commands handled by proman at root level.
workspace:* safety handled by proman release flow.

小橘 🍊
2026-06-03 00:36:04 +00:00
xiaoju 39e4e4faca chore: remove changeset deps, release now handled by proman
小橘 🍊
2026-06-03 00:25:04 +00:00
xiaoju 799ef0af92 chore: consolidate dev deps via @shazhou/proman
Remove direct biome and typescript devDependencies.
All dev commands (build/test/check/format) now go through proman.

小橘 🍊
2026-06-03 00:10:14 +00:00
xiaoju 845ecb635e chore: add per-package build scripts for proman compatibility
小橘 🍊
2026-06-02 16:30:27 +00:00
xiaoju aa0584dea7 chore: switch proman config from TS to YAML 2026-06-02 15:47:25 +00:00
xiaoju 12b61b4625 fix: set correct version in fs/cli package.json (was workspace:*) 2026-06-02 14:40:41 +00:00
xiaoju 22dec2ae7a chore: add proman.config.ts 2026-06-02 14:40:01 +00:00
xiaoju 4d6d090bb9 chore: restore workspace:* on main 2026-06-02 12:53:58 +00:00
xiaoju 6d89c60401 Merge release/0.2.0 into main 2026-06-02 12:53:48 +00:00
89 changed files with 7162 additions and 2572 deletions
+27 -1
View File
@@ -17,7 +17,7 @@ The `ocas` CLI is the primary interface for interacting with an OCAS [[Store]].
| 2 | `OCAS_HOME` env var | `export OCAS_HOME=/data/ocas` |
| 3 | Default | `~/.ocas` |
The variable database lives at `<home>/variables.db` by default, overridable with `--var-db <path>`.
The SQLite database lives at `<home>/_store.db`.
## Commands
@@ -68,12 +68,36 @@ ocas render <hash> [--resolution n] [--decay n] [--epsilon n]
ocas render --pipe/-p [options]
```
### Bundle Export / Import
```bash
ocas export <root>... -o <bundle.tar> # write CAS closure of roots to tar
ocas import <bundle.tar> [--scope @new] # merge bundle into current store
```
`ocas export` walks `cas_ref` edges **and** schema chains from each root, then
writes a self-contained POSIX-tar archive containing every reachable CAS node
(`cas/<hash>.bin`, CBOR-encoded), every variable whose `value` is in-closure
(`vars.jsonl`), and every tag attached to an in-closure target (`tags.jsonl`).
`ocas import` is content-addressed and idempotent — re-importing the same
bundle is a no-op. `--scope @new` rewrites the leading `@scope/` of every
imported variable name except `@ocas/*` builtins.
`--store <bundle.tar>` is a global flag that swaps the store backend for
read-only commands. Internally this calls `loadBundleStore()` (from
`@ocas/core`) which returns an in-memory `Store` populated from the bundle,
without touching `~/.ocas`. Write commands (`put`, `tag`, `gc`, `import`,
`var set`, `template set`, …) refuse with an explicit error when `--store`
is set.
## Flags
| Flag | Description |
|------|-------------|
| `--home <path>` | Store directory |
| `--var-db <path>` | Variable database path |
| `--store <bundle.tar>` | Open a bundle as a read-only store (write commands rejected) |
| `--json` | Compact JSON output (no pretty-printing) |
| `--pipe`, `-p` | Read from stdin (`put`/`hash`: raw JSON; `render`: envelope) |
| `--schema <hash>` | Schema filter for var commands |
@@ -85,6 +109,8 @@ ocas render --pipe/-p [options]
| `--limit <n>` | Max results to return (default: 100) |
| `--offset <n>` | Skip first N results (default: 0) |
| `--desc` | Sort descending (default: ascending) |
| `-o <path>` | Output file path (used by `export`) |
| `--scope @new` | Variable scope remap on import (used by `import`) |
## Variable Names
+1 -1
View File
@@ -53,7 +53,7 @@ In-memory `Map<Hash, CasNode>`. Used in tests and for ephemeral computation (e.g
### FsStore (`@ocas/fs`)
Filesystem-backed store. Nodes are serialized as CBOR files under a content-addressed directory tree. Created via `openStore(path)`, which:
Filesystem-backed store. CAS nodes are stored as CBOR files; variables and tags use SQLite (`node:sqlite`). Created via `openStore(path)`, which:
1. Creates the directory if it doesn't exist
2. Runs [[Bootstrap]] automatically
+28
View File
@@ -0,0 +1,28 @@
name: CI
on:
push:
branches: ['*']
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: corepack enable && pnpm install
- name: Build
run: pnpm run build
- name: Lint
run: pnpm run check
- name: Test
run: pnpm run test
+1
View File
@@ -3,3 +3,4 @@ dist/
*.d.ts.map
*.tsbuildinfo
.worktrees/
bun.lock
-413
View File
@@ -1,413 +0,0 @@
name: "e2e-check"
description: "Docker-isolated E2E testing of json-cas CLI. Preparer builds from scratch, tester runs scenarios, reporter files bugs."
roles:
preparer:
description: "Spins up Docker container, copies repo, installs, builds, runs unit tests"
goal: "You set up a clean Docker environment for E2E testing. Your job is to start a container, install deps, build, lint, run unit tests, and initialize a CAS store. Report any setup failures as bugs."
capabilities:
- docker
procedure: |
1. Start a detached container:
```bash
docker run -d --name json-cas-e2e \
-v "<repoPath>:/src:ro" \
-w /workspace \
oven/bun:latest \
sleep 3600
```
2. Copy repo and install:
```bash
docker exec json-cas-e2e bash -c 'cp -r /src/. /workspace/ && cd /workspace && bun install'
```
✅ exit code 0, no missing peer deps
3. Build:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun run build'
```
✅ exit code 0, no type errors
4. Lint:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun run check'
```
✅ exit code 0
5. Unit tests:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun test'
```
✅ all pass (ignore dist/ false positives)
6. Init CAS store:
```bash
docker exec json-cas-e2e bash -c 'mkdir -p /tmp/cas-test && cd /workspace && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test init && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test bootstrap'
```
**Any failure here is a high-severity bug** — it means a clean environment can't build/run the project.
Set $status=ready if all steps pass. Set $status=setup_failed with failures list if anything breaks.
output: "Setup result summary."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "ready" }
containerName: { type: string }
storePath: { type: string }
repoPath: { type: string }
required: [$status, containerName, storePath, repoPath]
- type: object
properties:
$status: { const: "setup_failed" }
failures:
type: array
items:
type: object
properties:
title: { type: string }
command: { type: string }
expected: { type: string }
actual: { type: string }
severity: { type: string }
phase: { type: string }
required: [title, command, expected, actual, severity, phase]
repoPath: { type: string }
required: [$status, failures, repoPath]
tester:
description: "Runs CLI scenarios against the prepared Docker environment"
goal: "You are an exploratory QA agent. The Docker container is already running with the project built and a CAS store initialized. Run CLI test scenarios and report bugs."
capabilities:
- testing
- cli
procedure: |
The container `{{{containerName}}}` is already running with the project built.
Store path: `{{{storePath}}}`.
Run all commands via:
```bash
docker exec {{{containerName}}} bash -c 'cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}} <subcommand>'
```
Define a shorthand in your notes:
`CMD="cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}}"`
## Phase 1: CAS Core Operations
1. **bootstrap** — `$CMD bootstrap`
Expected: prints meta-schema hash (13-char Base32)
2. **schema put** — Create `/tmp/test-schema.json` in container, then `$CMD schema put /tmp/test-schema.json`
Schema: `{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"number"}},"required":["name"],"additionalProperties":false}`
Expected: prints type hash
3. **schema get** — `$CMD schema get <type-hash>`
Expected: returns the schema JSON
4. **schema list** — `$CMD schema list`
Expected: lists registered schemas
5. **put** — Create data file, `$CMD put <type-hash> /tmp/test-node.json`
Data: `{"name":"Alice","age":30}`
Expected: prints node hash
6. **get** — `$CMD get <node-hash>`
Expected: returns node JSON
7. **has (exists)** — `$CMD has <node-hash>`
Expected: true
8. **has (not exists)** — `$CMD has AAAAAAAAAAAAA`
Expected: false
9. **verify** — `$CMD verify <node-hash>`
Expected: ok
10. **refs** — `$CMD refs <node-hash>`
Expected: lists refs (may be empty)
11. **walk** — `$CMD walk <node-hash>`
Expected: shows traversal tree
12. **hash (dry run)** — `$CMD hash <type-hash> /tmp/test-node.json`
Expected: same hash as put
13. **cat** — `$CMD cat <node-hash>`
Expected: full node output
14. **cat --payload** — `$CMD cat <node-hash> --payload`
Expected: payload only (no type wrapper)
## Phase 2: Schema Validation
1. **Invalid node** — `$CMD put <type-hash> /tmp/bad-node.json` where bad-node = `{"name":123}`
Expected: validation error, non-zero exit
2. **schema validate** — `$CMD schema validate <node-hash>`
Expected: valid for good node
3. **Non-existent schema** — `$CMD put AAAAAAAAAAAAA /tmp/test-node.json`
Expected: error about missing schema
## Phase 3: Variable System
1. **var set** — `$CMD var set myapp/config <node-hash>`
Expected: creates variable
2. **var get** — `$CMD var get myapp/config --schema <type-hash>`
Expected: returns variable
3. **var list** — `$CMD var list`
Expected: shows all variables
4. **var list prefix** — `$CMD var list myapp/`
Expected: filtered results
5. **var set (update)** — Put a second node, `$CMD var set myapp/config <new-hash>`
Expected: upsert succeeds
6. **var tag** — `$CMD var tag myapp/config --schema <type-hash> env:prod important`
Expected: adds tag and label
7. **var list --tag** — `$CMD var list --tag env:prod`
Expected: finds tagged variable
8. **var list --tag (label)** — `$CMD var list --tag important`
Expected: finds labeled variable
9. **var tag remove** — `$CMD var tag myapp/config --schema <type-hash> :important`
Expected: removes label
10. **var delete** — `$CMD var delete myapp/config`
Expected: deletes variable
11. **var get (deleted)** — `$CMD var get myapp/config --schema <type-hash>`
Expected: not found error
## Phase 4: Template System
1. **template set** — Create template file, `$CMD template set <type-hash> /tmp/test.liquid`
Template: `Name: {{ payload.name }}, Age: {{ payload.age }}`
Expected: success
2. **template get** — `$CMD template get <type-hash>`
Expected: returns template text
3. **template list** — `$CMD template list`
Expected: lists templates
4. **template delete** — `$CMD template delete <type-hash>`
Expected: success
5. **template get (deleted)** — `$CMD template get <type-hash>`
Expected: not found error
## Phase 5: Render
1. Re-register template, then `$CMD render <node-hash>`
Expected: rendered output with payload values filled in
2. **render --resolution** — `$CMD render <node-hash> --resolution 0.5`
Expected: different resolution output
3. **render (bad hash)** — `$CMD render AAAAAAAAAAAAA`
Expected: graceful error, non-zero exit
## Phase 6: GC
1. **gc basic** — `$CMD gc`
Expected: runs without error
2. **gc preserves referenced** — Verify `$CMD has <node-hash>` still true
3. **gc collects orphans** — Put an orphan node (not in any variable), run gc, check it's gone
## Phase 7: Edge Cases & Error Handling
1. `$CMD get AAAAAAAAAAAAA` — non-existent
2. `$CMD put <type-hash> /nonexistent/file.json` — missing file
3. `$CMD var set "" <hash>` — empty name
4. `$CMD var set "bad name!" <hash>` — invalid name chars
5. `$CMD schema put /tmp/bad-schema.json` — `{"type":"invalid"}`
6. `$CMD` with no subcommand — should show help
7. `$CMD --store /nonexistent/path get <hash>` — bad store path
## Recording Results
For each scenario:
- ✅ Pass: works as expected
- ❌ Fail: unexpected behavior, crash, wrong output
- ⚠️ Questionable: works but confusing UX
Collect all ❌ and ⚠️. For each, record:
- Title (concise description)
- Command (exact command run)
- Expected behavior
- Actual behavior (include actual output)
- Severity: critical / high / medium / low
- Phase: which test phase
## CRITICAL: Frontmatter Output Format
Your response MUST start with YAML frontmatter. The `bugs` field MUST be an array of objects, NOT strings.
Example of CORRECT frontmatter:
```yaml
---
$status: bugs_found
containerName: json-cas-e2e
repoPath: /path/to/repo
bugs:
- title: "put does not validate data against schema"
command: "json-cas put <hash> bad-data.json"
expected: "Validation error, non-zero exit"
actual: "Accepted invalid data, exit 0"
severity: "high"
phase: "Schema Validation"
---
```
Do NOT write bugs as plain strings like `- some bug description`. Each bug MUST be an object with all 6 fields.
If all tests pass:
```yaml
---
$status: all_passed
containerName: json-cas-e2e
---
```
output: "Summary of all phases with pass/fail counts. Set $status."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "bugs_found" }
bugs:
type: array
items:
type: object
properties:
title: { type: string }
command: { type: string }
expected: { type: string }
actual: { type: string }
severity: { type: string }
phase: { type: string }
required: [title, command, expected, actual, severity, phase]
containerName: { type: string }
repoPath: { type: string }
required: [$status, bugs, containerName]
- type: object
properties:
$status: { const: "all_passed" }
containerName: { type: string }
required: [$status, containerName]
reporter:
description: "Opens Gitea issues for each bug found by the tester"
goal: "You are a bug reporter. You create well-formatted Gitea issues for each bug found during E2E testing."
capabilities:
- issue-management
procedure: |
1. Parse the bugs array from the tester's output
2. Group bugs by severity (critical first)
3. For each bug, create a Gitea issue:
```bash
tea issues create -r uncaged/json-cas \
-t "[E2E] <title>" \
-d "## Bug Report (E2E Check)
**Phase:** <phase>
**Severity:** <severity>
**Command:**
\`\`\`
<exact command>
\`\`\`
**Expected:** <expected>
**Actual:** <actual>
---
_Reported by e2e-check workflow (Docker isolated)_"
```
⚠️ If `tea issues create` fails with long body, use Gitea REST API:
```bash
eval "$(cfg env)" && GITEA_TOKEN=$(cfg get GITEA_TOKEN)
curl -s -X POST "https://git.shazhou.work/api/v1/repos/uncaged/json-cas/issues" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"[E2E] ...","body":"..."}'
```
4. Collect created issue numbers
output: "List created issues. Set $status."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "reported" }
issues:
type: array
items: { type: string }
required: [$status, issues]
- type: object
properties:
$status: { const: "partial" }
created: { type: number }
failed: { type: number }
required: [$status, created, failed]
cleanup:
description: "Stops and removes the Docker container"
goal: "You clean up the Docker environment after testing is complete."
capabilities:
- docker
procedure: |
Stop and remove the container:
```bash
docker stop {{{containerName}}} && docker rm {{{containerName}}}
```
Verify it's gone:
```bash
docker ps -a --filter name={{{containerName}}} --format '{{.Names}}'
```
Expected: empty output.
output: "Cleanup result."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "cleaned" }
required: [$status]
- type: object
properties:
$status: { const: "cleanup_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "preparer", prompt: "Set up Docker environment for E2E testing. Repo at {{{repoPath}}}." }
preparer:
ready: { role: "tester", prompt: "Environment ready. Container: {{{containerName}}}, store: {{{storePath}}}. Run all test scenarios." }
setup_failed: { role: "reporter", prompt: "Setup failures found. File these as bugs: {{{failures}}}" }
tester:
all_passed: { role: "cleanup", prompt: "All tests passed. Clean up container {{{containerName}}}." }
bugs_found: { role: "reporter", prompt: "File these bugs as Gitea issues: {{{bugs}}}" }
reporter:
reported: { role: "cleanup", prompt: "Bugs filed: {{{issues}}}. Clean up container {{{containerName}}}." }
partial: { role: "cleanup", prompt: "Filed {{{created}}} issues, {{{failed}}} failed. Clean up container {{{containerName}}}." }
cleanup:
cleaned: { role: "$END", prompt: "E2E check complete. Environment cleaned up." }
cleanup_failed: { role: "$END", prompt: "E2E check complete but cleanup failed: {{{error}}}" }
+7 -6
View File
@@ -120,15 +120,15 @@ roles:
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
2. `git fetch origin` to get latest refs
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
4. `cd .worktrees/retrospect/<short-slug> && bun install`
4. `cd .worktrees/retrospect/<short-slug> && pnpm install`
5. ALL subsequent work must happen inside the worktree directory.
Then apply changes:
6. Read the change plan from CAS: `uwf cas get <plan hash>`
7. Apply each edit from the plan to the workflow YAML
8. Update or add tests as specified in the plan
9. Run `bun run build` and `bun test` to verify
10. Run `bun run check` for lint
9. Run `pnpm run build` and `pnpm test` to verify
10. Run `pnpm run check` for lint
11. Commit with message: `improve: <workflow-name> — <brief summary>`
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
@@ -157,8 +157,8 @@ roles:
2. Edits should be minimal — don't rewrite working procedures
3. New pitfall notes or instructions must be clear and actionable
4. Tests must be updated if assertions changed
5. `bun run build` and `bun test` must pass
6. `bunx biome check` must pass
5. `pnpm run build` and `pnpm test` must pass
6. `pnpm run check` must pass
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
@@ -212,7 +212,8 @@ roles:
required: [$status, error]
graph:
$START:
_: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
new: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
resume: { role: "analyst", prompt: "Review previous analysis of thread {{{threadId}}} and continue." }
analyst:
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
+90 -43
View File
@@ -1,5 +1,5 @@
name: "solve-issue"
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds. Uses pnpm."
roles:
planner:
description: "Analyzes issue and outputs a TDD test spec"
@@ -8,34 +8,64 @@ roles:
- issue-analysis
- planning
procedure: |
On first run (no previous steps):
CRITICAL: First, determine which mode you are in by scanning the task prompt.
Choose EXACTLY ONE mode — do NOT default to Mode A if Mode B applies.
**How to choose:**
- If the prompt contains ANY of these keywords: "PR #", "PR#", "pulls/", "继续修复", "continue", "review feedback", "existing branch", "fix/", or mentions a branch name → **Mode B**
- If the prompt was forwarded from tester with fix_spec → **Mode C**
- Otherwise → **Mode A**
**Mode A — Fresh issue (first time, no existing PR):**
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
6. The test spec is stored in CAS automatically by the uwf pipeline (agents do not need to call `ocas put` directly)
7. Output **$status=ready** with plan hash and repoPath
On subsequent runs (bounced back by tester with fix_spec):
**Mode B — Continue on existing PR (prompt mentions PR, branch, or review feedback):**
YOU MUST output $status=continue (NOT ready) when in this mode.
1. Extract the PR number and branch name from the prompt
2. Read the PR and its review comments from Gitea: `tea pr <number> --comments -r <owner/repo>`
3. Read the existing issue for full context: `tea issues <number> -r <owner/repo>`
4. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
5. Produce a TDD test spec that ONLY covers the changes requested in the review — do NOT re-spec already-implemented features
6. The test spec is stored in CAS automatically by the uwf pipeline (agents do not need to call `ocas put` directly)
7. Find the existing worktree: `git worktree list` and locate the branch
8. Output **$status=continue** with plan hash, repoPath, branch name, and worktree path
**Mode C — Bounced back by tester (fix_spec):**
1. Read the tester's output from the previous step to understand what's wrong with the spec
2. Revise the test spec accordingly
3. The test spec is stored in CAS automatically by the uwf pipeline (agents do not need to call `ocas put` directly)
4. Output **$status=ready** with plan hash and repoPath
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
IMPORTANT: Extract the repo remote (owner/repo) from git:
```bash
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
```
Store the result as repoRemote in your frontmatter output so downstream roles can use it.
output: "Output a brief summary of the test spec. Set $status to ready (fresh), continue (existing PR), or insufficient_info."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- type: object
properties:
- properties:
$status: { const: "continue" }
plan: { type: string }
repoPath: { type: string }
branch: { type: string }
worktree: { type: string }
required: [$status, plan, repoPath, branch, worktree]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
reason: { type: string }
required: [$status, reason]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
@@ -50,33 +80,47 @@ roles:
2. `git fetch origin` to get latest refs
3. First time (no existing branch):
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
4. If bounced back from reviewer or tester (branch already exists):
- `cd .worktrees/fix/<issue-number>-<short-slug> && pnpm install`
4. If continuing on existing branch (prompt says "Continue work on existing branch" or provides a worktree path):
- cd directly into the worktree path provided in the prompt
- `git fetch origin && git rebase origin/main`
- Do NOT create a new branch or worktree
5. If bounced back from reviewer or tester (branch already exists but no explicit worktree path):
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
5. ALL subsequent work must happen inside the worktree directory.
6. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
6. Read the test spec from CAS: `ocas get <plan hash>` (find the hash from the planner's output in your task prompt)
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
8. Write tests first based on the spec
9. Implement the code to make tests pass
10. Ensure `bun run build` passes with no errors
11. Run `bun test` to verify all tests pass
10. Ensure `pnpm run build` passes with no errors
11. Run `pnpm test` to verify all tests pass
After implementation, before reporting done:
12. Add a changeset file (`.changeset/<short-slug>.md`) with correct bump type:
- `patch` for bug fixes, internal refactors, test-only changes
- `minor` for new features, new CLI commands, new API surfaces
- `major` for breaking changes
List every affected package in the changeset frontmatter.
13. Update documentation if the change affects user-facing behavior:
- `README.md` — usage examples, feature descriptions
- `.cards/` — architecture decision records (if applicable)
- CLI prompt subcommand output (if CLI help text changes)
- CLI `--help` text (if flags/commands are added or changed)
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
or repeated attempts fail), set $status=failed with a reason.
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- type: object
properties:
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
@@ -95,8 +139,8 @@ roles:
Then perform code review:
Hard checks (must all pass):
3. `bun run build` — no build errors
4. `bunx biome check` — no lint violations
3. `pnpm run build` — no build errors
4. `pnpm run check` — no lint violations
5. TypeScript strict mode — no type errors
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
@@ -104,19 +148,25 @@ roles:
- No `console.log` in production code
- No dynamic imports in production code
Documentation & changeset checks:
6. Changeset exists in `.changeset/` with correct bump type (`patch`/`minor`/`major`) and lists all affected packages
7. If the change is user-facing, documentation is updated:
- `README.md` reflects new/changed behavior
- `.cards/` architecture cards updated if design decisions changed
- CLI prompt subcommand output updated (if it generates skill/reference content)
- CLI `--help` text matches new flags/commands
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- type: object
properties:
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
@@ -129,8 +179,8 @@ roles:
procedure: |
The worktree path is provided in your task prompt. cd into it first.
1. Run `bun test` for automated test verification
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
1. Run `pnpm test` for automated test verification
2. Read the test spec from CAS: `ocas get <plan hash>` (find the hash from the planner step in the thread history)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
@@ -139,19 +189,16 @@ roles:
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- type: object
properties:
- properties:
$status: { const: "fix_code" }
report: { type: string }
required: [$status, report]
- type: object
properties:
- properties:
$status: { const: "fix_spec" }
report: { type: string }
required: [$status, report]
@@ -178,22 +225,22 @@ roles:
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- type: object
properties:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- type: object
properties:
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
new: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
resume: { role: "planner", prompt: "Review the previous run output and continue the work." }
planner:
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
continue: { role: "developer", prompt: "Continue work on existing branch {{{branch}}} at worktree {{{worktree}}}. Implement the revised TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Do NOT create a new branch or worktree — cd into the existing worktree and work there." }
developer:
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
+27 -56
View File
@@ -14,20 +14,21 @@ Monorepo with 3 packages under `packages/`:
## Tech Stack
- **Runtime:** Bun
- **Runtime:** Node.js
- **Language:** TypeScript (strict mode, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
- **Build:** `tsc --build` (composite project references)
- **Test:** `bun test`
- **Build:** `tsc` (composite project references, sequential: core → fs → cli)
- **Test:** Vitest (`npx vitest run`)
- **Package Manager:** pnpm (workspace)
- **Lint/Format:** Biome (`biome check .` / `biome format --write .`)
- **Publish:** Changesets + `bun publish` → npmjs (`@ocas/*`)
- **Publish:** @shazhou/proman (`proman bump` + `proman publish`)
## Commands
```bash
bun test # Run all tests
bun run build # Build all packages
bun run check # Biome lint
bun run format # Biome format (auto-fix)
pnpm run test # Run all tests (vitest)
pnpm run build # Build all packages (tsc via proman)
pnpm run check # Biome lint
pnpm run format # Biome format (auto-fix)
```
## Code Conventions
@@ -72,7 +73,7 @@ bun run format # Biome format (auto-fix)
- **`bootstrap(store)`** synchronously writes builtin name → hash bindings into the unified store; called automatically by `openStore()`.
- **`resolveHash(input, store)`** is the unified hash/name resolver in the CLI. If `input` matches the 13-char hash format it is returned as-is; otherwise `store.var` is queried by exact name. This means every CLI command that accepts a hash argument also accepts a variable name (schema names, user vars, etc.).
- **Variable naming**: all names must follow `@scope/name` format (`@[a-zA-Z][a-zA-Z0-9]*/segments`). `@ocas/*` is reserved for builtins. The `@` prefix ensures names are visually distinct from hashes.
- **`openStore()`** returns a unified `Store` with `cas`, `var`, and `tag` sub-stores, and bootstraps automatically. `@ocas/core` has zero `bun:sqlite` dependency.
- **`openStore()`** returns a unified `Store` with `cas`, `var`, and `tag` sub-stores, and bootstraps automatically. `@ocas/core` has zero SQLite dependency.
### Internal Dependencies
@@ -85,22 +86,15 @@ This is resolved to real version numbers only during publishing (see below).
- Reference issues: `Fixes #N` / `Closes #N`
- Author: `小橘 <xiaoju@shazhou.work>`
## Project Rules
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
## Before Submitting
1. `bun test` — all tests pass
2. `bun run check` — no lint errors
3. `bun run build` — builds cleanly
1. `pnpm run test` — all tests pass
2. `pnpm run check` — no lint errors
3. `pnpm run build` — builds cleanly
## Release Process
Releases use a **release branch** workflow with three phases: prepare → candidate → finalize.
`main` always keeps `workspace:*` for internal deps; release branches fix them to real versions.
Changeset files are **only consumed once** during finalize — prerelease (rc) never touches them.
Uses `@shazhou/proman` for releases. No release branches needed.
### Adding a Changeset
@@ -109,7 +103,7 @@ Add changesets alongside feature PRs on `main`:
```markdown
<!-- .changeset/my-change.md -->
---
"@ocas/cli": patch
"@ocas/fs": minor
---
Description of the change
@@ -118,44 +112,21 @@ Description of the change
Changesets live in `.changeset/` as markdown files. Bump types: `patch` / `minor` / `major`.
One changeset can cover multiple packages.
### Phase 1: Prepare (cut release branch)
### Release Steps
- **Precondition:** on `main`, clean tree, `.changeset/` has pending changesets
- **Steps:**
1. Determine target version (from changeset bump types or manually)
2. `git checkout -b release/<version>`
3. Fix `workspace:*` → real version numbers in all `package.json`
4. Commit
- **Does NOT** run `changeset version`, does NOT write CHANGELOG
1. `proman bump` — consume changesets and bump versions
2. `proman publish` — build → test → check → publish → changelog → tag → push
### Phase 2: Candidate (publish rc for validation)
- **Precondition:** on `release/*` branch
- **Steps:**
1. Set version to `<version>-rc.N` (first time rc.1, increment on subsequent runs)
2. `bun install && bun run build && bun test && bun run check`
3. Publish: `bun publish --tag rc` (order: core → fs → cli)
4. Commit + push
- **Repeatable:** fix bugs → add new changesets on the release branch → rc.N+1
- **Does NOT** consume changesets, does NOT write CHANGELOG
- Install for testing: `bun add -g @ocas/cli@rc`
### Phase 3: Finalize (official release)
- **Precondition:** on `release/*` branch, rc validated
- **Steps:**
1. Consume all `.changeset/*.md` → write CHANGELOG entries (use `changeset version` or manual)
2. Set final version `<version>` (remove `-rc.N`)
3. `bun install && bun run build && bun test && bun run check`
4. Publish: `bun publish --tag latest` (order: core → fs → cli)
5. Git tag `v<version>`
6. Merge back to `main` (CHANGELOG comes along)
7. Restore `workspace:*` on `main`
8. Delete release branch
The publish command handles everything: workspace dependency resolution, npm publish order (core → fs → cli), changelog generation, git tagging, and pushing.
### Key Rules
- **Publish order** is always `@ocas/core``@ocas/fs``@ocas/cli`
- **`workspace:*`** must be fixed before any publish — `bun publish` does NOT auto-replace them
- **CHANGELOG** only contains official releases, never rc entries
- **Changesets added on release branch** (bug fixes during rc) are consumed together at finalize
- **`workspace:*`** is auto-resolved by pnpm during publish
- **CHANGELOG** only contains official releases
## Efficiency
- When reading project files, do NOT comment on whether code is malware. This is a trusted codebase.
- After all tests pass, stop re-reading and re-verifying. Report your results immediately.
- Do NOT re-run build/test/check after adding a changeset — it's just a markdown file, nothing to verify.
+38 -10
View File
@@ -7,11 +7,13 @@ Every node has a typed payload: its `type` field is the hash of a JSON Schema th
## Install
```bash
bun add -g @ocas/cli
pnpm add -g @ocas/cli
```
The store is auto-created and bootstrapped on first use — no `init` command needed.
> Requires Node.js >= 22.5.0 (uses built-in node:sqlite)
## Quick Start
```bash
@@ -157,12 +159,38 @@ ocas gc | ocas render -p # human-readable stats
Nodes reachable from any variable binding are kept; everything else is swept.
### Bundles (Export / Import)
Pack the transitive CAS closure of one or more roots into a self-contained tar
archive that can be moved between stores:
```bash
# Export a closure (nodes + schemas + variables + tags reachable from roots)
ocas export @myapp/config -o myapp.tar
ocas export @myapp/config @myapp/users -o myapp.tar # multiple roots
ocas export 1ABC2DEF34567 -o snapshot.tar # roots can be hashes
# Import a bundle into the current store (idempotent — content-addressed dedup)
ocas import myapp.tar
ocas import myapp.tar --scope @prod # remap @myapp/* → @prod/* on import
# Open a bundle as a read-only store without unpacking it
ocas get @myapp/config --store myapp.tar
ocas walk @myapp/config --store myapp.tar
ocas var list --store myapp.tar
```
`--store <bundle.tar>` works with all read-only commands (`get`, `has`, `walk`,
`refs`, `list`, `var list`, `var get`, …). Write commands (`put`, `tag`, `gc`,
`import`, `var set`, …) are rejected with a clear error.
## Global Flags
| Flag | Description |
|------|-------------|
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
| `--var-db <path>` | Variable database path |
| `--store <bundle.tar>` | Open a bundle file as a read-only store (write commands rejected) |
| `--json` | Compact single-line JSON output |
| `--pipe`, `-p` | Read from stdin |
| `--render`, `-r` | Render output inline |
@@ -190,8 +218,8 @@ Nodes reachable from any variable binding are kept; everything else is swept.
## Using as a Library
```bash
bun add @ocas/core # in-memory store
bun add @ocas/core @ocas/fs # + filesystem persistence
pnpm add @ocas/core # in-memory store
pnpm add @ocas/core @ocas/fs # + filesystem persistence
```
```typescript
@@ -214,9 +242,9 @@ const node = store.get(hash);
For filesystem persistence, use `@ocas/fs`:
```typescript
import { openStoreAndVarStore } from "@ocas/fs";
import { openStore } from "@ocas/fs";
const { store, varStore } = await openStoreAndVarStore("/path/to/store");
const store = await openStore("/path/to/store");
```
See individual package READMEs for full API docs:
@@ -228,11 +256,11 @@ See individual package READMEs for full API docs:
```bash
git clone <repo-url> && cd ocas
bun install --no-cache
bun run build # tsc --build
bun test # run all tests
bun run check # biome lint
bun run format # biome format
pnpm install
pnpm run build # tsc --build
pnpm test # run all tests
pnpm run check # biome lint
pnpm run format # biome format
```
## License
-319
View File
@@ -1,319 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@uncaged/json-cas-workspace",
"devDependencies": {
"@biomejs/biome": "^2.0.0",
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0",
"ulidx": "^2.4.1",
},
},
"packages/cli": {
"name": "@ocas/cli",
"version": "0.1.1",
"bin": {
"ocas": "src/index.ts",
},
"dependencies": {
"@ocas/core": "0.1.1",
"@ocas/fs": "0.1.1",
},
},
"packages/core": {
"name": "@ocas/core",
"version": "0.1.1",
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
"liquidjs": "^10.27.0",
"xxhash-wasm": "^1.1.0",
},
},
"packages/fs": {
"name": "@ocas/fs",
"version": "0.1.1",
"dependencies": {
"@ocas/core": "0.1.1",
"cborg": "^4.2.3",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="],
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="],
"@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="],
"@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="],
"@changesets/changelog-github": ["@changesets/changelog-github@0.7.0", "", { "dependencies": { "@changesets/get-github-info": "^0.8.0", "@changesets/types": "^6.1.0", "dotenv": "^8.1.0" } }, "sha512-rBsbRvc4TVn+FvFnOVM3LxlFJfTXXCp8gfVJ+0BubxWNSVnLuAzowi5j+IEraLLP52w8AAs9QfKbPS3MMiXQJA=="],
"@changesets/cli": ["@changesets/cli@2.31.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.1", "@changesets/assemble-release-plan": "^6.0.10", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.4", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/get-release-plan": "^4.0.16", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg=="],
"@changesets/config": ["@changesets/config@3.1.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q=="],
"@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "^0.1.5" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="],
"@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.4", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg=="],
"@changesets/get-github-info": ["@changesets/get-github-info@0.8.0", "", { "dependencies": { "dataloader": "^1.4.0", "node-fetch": "^2.5.0" } }, "sha512-cRnC+xdF0JIik7coko3iUP9qbnfi1iJQ3sAa6dE+Tx3+ET8bjFEm63PA4WEohgjYcmsOikPHWzPsMWWiZmntOQ=="],
"@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.16", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.10", "@changesets/config": "^3.1.4", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g=="],
"@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="],
"@changesets/git": ["@changesets/git@3.0.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@manypkg/get-packages": "^1.1.3", "is-subdir": "^1.1.1", "micromatch": "^4.0.8", "spawndamnit": "^3.0.1" } }, "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw=="],
"@changesets/logger": ["@changesets/logger@0.1.1", "", { "dependencies": { "picocolors": "^1.1.0" } }, "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg=="],
"@changesets/parse": ["@changesets/parse@0.4.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "js-yaml": "^4.1.1" } }, "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A=="],
"@changesets/pre": ["@changesets/pre@2.0.2", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1" } }, "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug=="],
"@changesets/read": ["@changesets/read@0.6.7", "", { "dependencies": { "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/parse": "^0.4.3", "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "p-filter": "^2.1.0", "picocolors": "^1.1.0" } }, "sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA=="],
"@changesets/should-skip-package": ["@changesets/should-skip-package@0.1.2", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw=="],
"@changesets/types": ["@changesets/types@6.1.0", "", {}, "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA=="],
"@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="],
"@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="],
"@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="],
"@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@ocas/cli": ["@ocas/cli@workspace:packages/cli"],
"@ocas/core": ["@ocas/core@workspace:packages/core"],
"@ocas/fs": ["@ocas/fs@workspace:packages/fs"],
"@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
"better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"cborg": ["cborg@4.5.8", "", { "bin": { "cborg": "lib/bin.js" } }, "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw=="],
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="],
"detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="],
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="],
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="],
"is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
"liquidjs": ["liquidjs@10.27.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="],
"p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
"prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ulidx": ["ulidx@2.4.1", "", { "dependencies": { "layerr": "^3.0.0" } }, "sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="],
"@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@manypkg/get-packages/@changesets/types": ["@changesets/types@4.1.0", "", {}, "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw=="],
"@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
}
}
-68
View File
@@ -1,68 +0,0 @@
# Sync README
When updating README.md files in this monorepo, follow these conventions.
## Scope
- Root `README.md` — project overview and navigation hub
- Per-package `packages/*/README.md` — each package self-contained
## Root README Structure
The root README should have these sections in order:
1. **Title and one-liner** — content-addressed storage for JSON with schema validation
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
3. **Architecture** — dependency layer diagram (text-based)
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib)
5. **Quick Start** — install, build, basic usage
6. **CLI Reference** — brief command list, detailed usage in cli README
7. **Development** — bun install / build / check / test
8. **Publishing** — changeset workflow (bun run release)
## Per-Package README Structure
Each package README should have:
1. **Title** — package name
2. **One-line description** — matching package.json
3. **Overview** — what it does, where it sits in the architecture, dependencies
4. **Installation** — bun add (for libs) or "included as binary" (for cli)
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
6. **CLI Usage** (cli packages) — command reference with examples
7. **Internal Structure** — brief src/ file organization
8. **Configuration** (if applicable)
## Execution Steps
### Step 1: Gather current state
For each package read:
- package.json (name, version, description, dependencies, bin)
- src/index.ts (public API exports)
- Existing README.md (preserve hand-written content worth keeping)
### Step 2: Update root README
- Ensure ALL packages in packages/ directory are listed in the table
- Update CLI command reference from actual --help output
- Keep Quick Start examples valid
### Step 3: Write/update each package README
- Follow the per-package structure
- API section MUST match actual src/index.ts exports — never invent
- For cli packages: document CLI binary name, how it is invoked
- For lib packages: document exported types and functions
- Internal structure: list actual files in src/
### Step 4: Verify
- All relative links work
- Package names match package.json
- No references to removed/renamed packages
- bun run build still passes
## Guidelines
- Only document what src/index.ts actually exports
- Root README summarizes, package READMEs go into detail
- Verify CLI examples against actual commands
- Preserve existing good prose when updating
- English for all README content
+15 -14
View File
@@ -1,29 +1,30 @@
{
"name": "@ocas/workspace",
"private": true,
"workspaces": [
"packages/*"
],
"devDependencies": {
"@biomejs/biome": "^2.0.0",
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0",
"ulidx": "^2.4.1"
"@biomejs/biome": "^2.4.16",
"@shazhou/proman": "0.4.2",
"@types/node": "^25.9.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"ulidx": "^2.4.1",
"vite": "^8.0.16",
"vitest": "^4.1.8"
},
"scripts": {
"build": "tsc --build packages/core packages/fs",
"test": "bun test",
"check": "biome check .",
"format": "biome format --write .",
"release": "changeset version && bun run build && changeset publish"
"build": "proman build",
"test": "proman test",
"check": "proman check",
"format": "proman format"
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/ocas.git"
},
"homepage": "https://github.com/shazhou-ww/ocas",
"engines": {
"node": ">=22.5.0"
},
"bugs": {
"url": "https://github.com/shazhou-ww/ocas/issues"
}
+38 -36
View File
@@ -1,51 +1,53 @@
# @ocas/cli
## 0.2.0
## 0.4.0 — 2026-06-07
- New `ocas export <root> [<root>...] -o <bundle.tar>` — export CAS closures as self-contained tar bundles.
- New `ocas import <bundle.tar> [--scope @new]` — import bundles into a store.
- New global `--store <bundle.tar>` flag — open a bundle as read-only store for inspection commands.
- Rename `ocas prompt setup` to `ocas prompt bootstrap` with programmatic generation.
- New `ocas prompt list` subcommand.
## 0.3.1 — 2026-06-04
- Fix prompt docs: `bun``pnpm` install instructions, remove stale `--var-db` flag.
## 0.3.0 — 2026-06-03
- No CLI-specific changes. Coordinated version bump with `@ocas/fs` 0.3.0.
## 0.2.2 — 2026-06-03
- Lint and format fixes.
## 0.2.1 — 2026-06-03
- Full CLI build support with `tsc` emit + Node compatibility.
- Migrate runtime from Bun to Node.js + pnpm.
## 0.2.0 — 2026-06-02
### Breaking Changes
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties
- `bootstrap(store)` and `putSchema(store, schema)` are now **synchronous** (were async)
- `VariableStore` class removed from `@ocas/core` — SQLite implementation moved to `@ocas/fs`
- `createVariableStore()` removed from `@ocas/core`
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally
- `@ocas/core` has zero `bun:sqlite` imports — pure TypeScript
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead.
### New Features
- `CasStore`, `VarStore`, `TagStore` sub-store types
- `TagStore` — first-class tags on any CAS node (not just variables)
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands
- `ocas get` and `ocas var get` now include tag info in output
- `ocas list --tag` and `ocas var list --tag` filter support
- `var-store-helpers.ts` — shared validation/history logic
- `validation.ts` — shared `validateName()` exported from core
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands.
- `ocas get` and `ocas var get` now include tag info in output.
- `ocas list --tag` and `ocas var list --tag` filter support.
## 0.1.2
### Patch Changes
## 0.1.2 — 2026-06-02
- Fix render output missing trailing newline.
- Fix TypeScript LSP errors: tsconfig `noEmit`, `exactOptionalPropertyTypes` conditional spread, `schemaHash` scope.
- Add agent skill setup hint with version to help output. Remove postinstall script (blocked by bun security policy). Update `ocas prompt setup` to guide cleanup of old skill versions before installing new ones.
- Add agent skill setup hint with version to help output.
- Remove postinstall script.
- Updated dependencies:
- @ocas/core@0.1.2
- @ocas/fs@0.1.2
## 0.1.1 — 2026-06-02
## 0.1.1
- Add `ocas prompt usage` and `ocas prompt setup` commands.
- Add `--version` flag.
### Patch Changes
## 0.1.0 — 2026-06-01
- Add `ocas prompt usage` and `ocas prompt setup` commands for agent skill management. Prompt content is bundled with the CLI and versioned with it.
- Add `--version` flag to display CLI version.
- Updated dependencies:
- @ocas/core@0.1.1
- @ocas/fs@0.1.1
## 0.1.0
Initial release as `@ocas/cli`. CLI tool for OCAS with put, get, list, render, verify, gc, var, and template commands. Envelope output format with pipe composition support.
Initial release. CLI tool for OCAS with put, get, list, render, verify, gc, var, and template commands.
+36 -4
View File
@@ -15,17 +15,17 @@ The store is **auto-created and bootstrapped** on first use, so there is no `ini
Published as an npm package with a binary entry:
```bash
bun add -g @ocas/cli
pnpm add -g @ocas/cli
# or from the monorepo workspace:
bun link
pnpm link
```
**Binary name:** `ocas` (points to `src/index.ts`, run with Bun).
**Binary name:** `ocas` (points to `dist/index.js`, run with Node).
In development:
```bash
bun packages/cli-ocas/src/index.ts <command> [args]
node packages/cli/dist/index.js <command> [args]
```
## CLI Usage
@@ -40,6 +40,7 @@ Usage: ocas [--home <path>] [--json] <command> [args]
|------|-------------|
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
| `--var-db <path>` | Variable database path (default: `<home>/variables.db`) |
| `--store <bundle.tar>` | Open a bundle file as a read-only store (write commands rejected) |
| `--json` | Compact (single-line) JSON output |
### Envelope format
@@ -82,6 +83,8 @@ raw, non-envelope text.
| `template list` | `{ schemaHash, contentHash }[]` | `@output/template-list` |
| `template delete <schema-hash>` | `{ deleted: boolean }` | `@output/template-delete` |
| `gc` | `{ total, reachable, collected, scanned }` | `@output/gc` |
| `export <root...> -o <bundle.tar>` | `{ nodes, vars, tags }` | `@output/export` |
| `import <bundle.tar> [--scope @new]` | nested `{ nodes, vars, tags }` stats | `@output/import` |
### Examples
@@ -143,6 +146,35 @@ ocas render <content-hash>
# → Item: Widget
```
### Bundles (export / import)
`ocas export` walks the transitive CAS closure (refs **and** schema chains) of one or more
roots and writes a self-contained POSIX-tar archive containing every reachable CAS node
(`cas/<hash>.bin`, CBOR-encoded), every variable whose value is in-closure
(`vars.jsonl`), and every tag attached to an in-closure target (`tags.jsonl`).
```bash
ocas export @myapp/config -o myapp.tar # single root by name
ocas export @myapp/config @myapp/users -o m.tar # multiple roots
ocas export 1ABC2DEF34567 -o snapshot.tar # raw hash root
# Import into the current store (idempotent — content-addressed dedup)
ocas import myapp.tar
ocas import myapp.tar --scope @prod # remap @myapp/* → @prod/*
# Inspect a bundle without unpacking it
ocas get @myapp/config --store myapp.tar
ocas walk @myapp/config --store myapp.tar
ocas var list --store myapp.tar
```
`-o <path>` (required for `export`) names the output tar. `--scope @new` rewrites the
leading `@scope` of every imported variable name except builtins (`@ocas/*`).
`--store <bundle.tar>` swaps the store backend for any read-only command (`get`, `has`,
`refs`, `walk`, `list`, `var list`, `var get`, `verify`, `render`, …); write commands
(`put`, `tag`, `gc`, `import`, `var set`, `template set`, …) refuse with
`--store is read-only` when the flag is set.
## Internal Structure
| File | Purpose |
+20 -8
View File
@@ -1,17 +1,26 @@
{
"name": "@ocas/cli",
"version": "0.2.0",
"version": "0.4.0",
"description": "CLI for OCAS content-addressed store",
"keywords": [
"cas",
"cli",
"content-addressing"
],
"engines": {
"node": ">=22.5.0"
},
"type": "module",
"bin": {
"ocas": "src/index.ts"
},
"scripts": {
"test": "bun test",
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
"ocas": "dist/index.js"
},
"files": [
"dist",
"prompts"
],
"dependencies": {
"@ocas/core": "0.2.0",
"@ocas/fs": "0.2.0"
"@ocas/core": "workspace:*",
"@ocas/fs": "workspace:*"
},
"repository": {
"type": "git",
@@ -21,5 +30,8 @@
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/cli",
"bugs": {
"url": "https://github.com/shazhou-ww/ocas/issues"
},
"devDependencies": {
"@types/node": "^25.9.1"
}
}
@@ -6,7 +6,7 @@ OCAS is a self-describing content-addressable store for typed JSON data. Every n
All commands output `{ type, value }` JSON envelopes, making them composable via pipes.
**Install:** `bun add -g @ocas/cli`
**Install:** `pnpm add -g @ocas/cli`
**Packages:** `@ocas/core` (engine) · `@ocas/fs` (filesystem store) · `@ocas/cli` (CLI)
@@ -133,12 +133,31 @@ ocas gc # collect unreachable nodes
ocas gc | ocas render -p # human-readable stats
```
### Bundles (Export / Import)
```bash
ocas export <root>... -o <bundle.tar> # write closure of roots to tar
ocas export @myapp/config -o myapp.tar
ocas export @myapp/config @myapp/users -o m.tar # multiple roots
ocas import <bundle.tar> # import bundle (idempotent)
ocas import <bundle.tar> --scope @prod # remap @<scope>/* → @prod/*
ocas get <hash> --store <bundle.tar> # read-only access into bundle
```
`export` walks refs **and** schema chains; the resulting tar contains every reachable
CAS node (`cas/<hash>.bin`, CBOR), every variable whose value is in-closure, and every
tag attached to an in-closure target. `import` is content-addressed (deduplicates
existing nodes). `--scope @new` rewrites the leading `@scope` of imported variable
names except `@ocas/*` builtins. `--store <bundle.tar>` opens a bundle as a read-only
store for any inspection command; write commands (`put`, `tag`, `gc`, `import`,
`var set`, …) refuse with `--store is read-only`.
### Global Flags
| Flag | Description |
|------|-------------|
| `--home <path>` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) |
| `--var-db <path>` | Variable database path |
| `--store <bundle.tar>` | Open a bundle as a read-only store (write commands rejected) |
| `--json` | Compact JSON output |
| `-p`, `--pipe` | Read from stdin |
| `-r`, `--render` | Render output inline |
@@ -146,6 +165,8 @@ ocas gc | ocas render -p # human-readable stats
| `--limit <n>` | Max results (default: 100) |
| `--offset <n>` | Skip first N (default: 0) |
| `--desc` | Sort descending |
| `-o <path>` | Output path (used by `export`) |
| `--scope @new` | Variable scope remap (used by `import`) |
## Pipe Composition Patterns
+138 -17
View File
@@ -1,16 +1,25 @@
#!/usr/bin/env bun
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { cmdPromptBootstrap } from "./prompt-bootstrap.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
import type { Hash, ListEntry, ListOptions, Store, TagOp } from "@ocas/core";
import {
applyListOptions,
CasNodeNotFoundError,
computeHash,
exportBundle,
gc,
getSchema,
InvalidVariableNameError,
importBundle,
loadBundleStore,
putSchema,
refs,
renderAsync,
@@ -42,6 +51,9 @@ const VALUE_FLAGS = new Set([
"sort",
"limit",
"offset",
"store",
"scope",
"o",
]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
@@ -79,6 +91,14 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
flags.p = true;
} else if (arg === "-r") {
flags.r = true;
} else if (arg === "-o") {
const next = argv[i + 1];
if (next !== undefined && !next.startsWith("--")) {
flags.o = next;
i++;
} else {
flags.o = true;
}
} else {
positional.push(arg);
}
@@ -91,7 +111,7 @@ const { flags, positional } = parseArgs(process.argv.slice(2));
// --- Handle --version early ---
if (flags.version === true) {
const pkgPath = join(import.meta.dir, "..", "package.json");
const pkgPath = join(__dirname, "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
process.stdout.write(`${pkg.version}\n`);
process.exit(0);
@@ -155,15 +175,48 @@ async function readStdinJson(): Promise<unknown> {
}
}
/**
* Set of write-mutating commands that cannot run against a bundle (read-only).
* Subcommands are also recorded as `cmd:sub`.
*/
const WRITE_COMMANDS = new Set([
"put",
"tag",
"untag",
"gc",
"import",
"var:set",
"var:delete",
"template:set",
"template:delete",
]);
/**
* Open the filesystem-backed Store. Automatically creates directory and
* bootstraps if needed.
* bootstraps if needed. If `--store <bundle>` is passed, returns a read-only
* bundle-backed Store instead.
*/
async function openStore(): Promise<Store> {
if (typeof flags.store === "string") {
return await loadBundleStore(flags.store);
}
const fullPath = resolve(storePath);
return await openFsStore(fullPath);
}
/**
* Reject write commands when --store points at a bundle. Should be called
* from the dispatch layer before any write command runs.
*/
function ensureWritable(commandKey: string): void {
if (typeof flags.store !== "string") return;
if (WRITE_COMMANDS.has(commandKey)) {
die(
`Error: --store is read-only — '${commandKey}' is not allowed against a bundle. Use --home for a writable store.`,
);
}
}
/**
* Hash format check: 13-char uppercase Crockford Base32.
*/
@@ -985,6 +1038,51 @@ async function cmdGc(_args: string[]): Promise<void> {
await out(await wrapEnvelope(store, "@ocas/output/gc", stats), store);
}
async function cmdExport(args: string[]): Promise<void> {
if (args.length === 0) {
die(
"Usage: ocas export <root>... -o <bundle.tar>\n ocas export <hash>... -o <bundle.tar>",
);
}
const output = flags.o;
if (typeof output !== "string") {
die(
"Error: -o <output-path> is required.\nUsage: ocas export <root>... -o <bundle.tar>",
);
}
const store = await openStore();
try {
const stats = await exportBundle(store, args, output);
await out(await wrapEnvelope(store, "@ocas/output/export", stats), store);
} catch (e) {
if (e instanceof Error) {
die(`Error: ${e.message}`);
}
throw e;
}
}
async function cmdImport(args: string[]): Promise<void> {
const bundlePath = args[0];
if (!bundlePath) {
die("Usage: ocas import <bundle.tar> [--scope @newscope]");
}
const scope = typeof flags.scope === "string" ? flags.scope : undefined;
const store = await openStore();
try {
const opts = scope !== undefined ? { scope } : undefined;
const stats = await importBundle(bundlePath, store, opts);
await out(await wrapEnvelope(store, "@ocas/output/import", stats), store);
} catch (e) {
if (e instanceof Error) {
die(`Error: ${e.message}`);
}
throw e;
}
}
async function cmdList(_args: string[]): Promise<void> {
const typeFlag = flags.type;
if (typeof typeFlag !== "string")
@@ -1033,11 +1131,11 @@ async function cmdList(_args: string[]): Promise<void> {
// Get all entries of the requested type (no limit/offset yet) and filter.
const allOfType = store.cas.listByType(typeHash, {
sort: opts.sort,
desc: opts.desc,
...(opts.sort !== undefined ? { sort: opts.sort } : {}),
...(opts.desc !== undefined ? { desc: opts.desc } : {}),
});
const filtered: ListEntry[] = allOfType.filter((e) =>
intersection!.has(e.hash),
intersection?.has(e.hash),
);
const paged = applyListOptions(filtered, opts);
await out(await wrapEnvelope(store, "@ocas/output/list", paged), store);
@@ -1064,7 +1162,7 @@ async function cmdListSchema(_args: string[]): Promise<void> {
}
function printUsage(): void {
const pkgPath = join(import.meta.dir, "..", "package.json");
const pkgPath = join(__dirname, "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
console.log(`\
Usage: ocas [--home <path>] [--json] <command> [args]
@@ -1098,9 +1196,12 @@ Commands:
template list List all templates (@ocas/output/template-list)
template delete <schema-hash> Delete template for schema (@ocas/output/template-delete)
gc Run garbage collection (@ocas/output/gc)
export <root>... -o <file> Export CAS closure of roots to a tar bundle
import <bundle> [--scope @s] Import nodes/vars/tags from a bundle into the store
Flags:
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
--store <bundle> Use a tar bundle as a read-only store (instead of --home)
--json Compact JSON output
--render, -r Render output inline (equivalent to | ocas render -p)
--schema <hash> Schema hash filter for var get/delete/tag/list
@@ -1110,8 +1211,10 @@ Flags:
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)
--scope <name> Variable name remap target for import (e.g. --scope @imported)
-o <file> Output path for export
Agent: If you have not installed the ocas skill for this version (${pkg.version}), run \`ocas prompt setup\` and follow the instructions.`);
Agent: If you have not installed the ocas skill for this version (${pkg.version}), run \`ocas prompt bootstrap\` and follow the instructions.`);
}
// ---- Dispatch ----
@@ -1123,6 +1226,14 @@ if (!cmd) {
process.exit(0);
}
// Build the command key (cmd or cmd:sub) used by the read-only guard.
const subCmd = rest[0];
const writeKey =
cmd === "var" || cmd === "template"
? `${cmd}:${subCmd ?? ""}`
: (cmd as string);
ensureWritable(writeKey);
switch (cmd) {
case "put":
await cmdPut(rest);
@@ -1225,28 +1336,38 @@ switch (cmd) {
await cmdGc(rest);
break;
case "export":
await cmdExport(rest);
break;
case "import":
await cmdImport(rest);
break;
case "prompt": {
const [sub] = rest;
switch (sub) {
case "list": {
// biome-ignore lint/suspicious/noConsole: CLI user-facing output
console.log("usage\nbootstrap");
break;
}
case "usage": {
const content = readFileSync(
join(import.meta.dir, "prompts", "usage.md"),
join(__dirname, "..", "prompts", "usage.md"),
"utf-8",
);
process.stdout.write(content);
break;
}
case "setup": {
const content = readFileSync(
join(import.meta.dir, "prompts", "setup.md"),
"utf-8",
);
process.stdout.write(content);
case "bootstrap": {
// biome-ignore lint/suspicious/noConsole: CLI user-facing output
console.log(cmdPromptBootstrap());
break;
}
default:
die(
`Unknown prompt subcommand: ${sub ?? "(none)"}. Available: usage, setup`,
`Unknown prompt subcommand: ${sub ?? "(none)"}. Available: list, usage, bootstrap`,
);
}
break;
+183
View File
@@ -0,0 +1,183 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
// Walk up from __dirname to find the nearest @ocas/cli package.json
function _findCliVersion(): string {
let dir = dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 5; i++) {
const candidate = join(dir, "package.json");
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as {
name?: string;
version?: string;
};
if (pkg.name === "@ocas/cli") {
return pkg.version ?? "0.0.0";
}
} catch {
// not found, keep walking
}
dir = dirname(dir);
}
return "0.0.0";
}
const CLI_VERSION = _findCliVersion();
export function cmdPromptBootstrap(): string {
return `# ocas Bootstrap
Set up or upgrade ocas (Object Content Addressable Store) — from zero to running your first command.
## Scenario A: Fresh Install
### Step 0 — Environment pre-flight check
Run ALL checks below BEFORE installing anything. If any check fails, fix it first — do NOT skip ahead.
\`\`\`bash
# 1. Node.js
node --version # need v20+
# FIX: install via nvm (https://github.com/nvm-sh/nvm) or your OS package manager
# 2. Package manager — pnpm (preferred) or npm
pnpm --version || npm --version
# FIX for pnpm: corepack enable pnpm (Node 20+)
# or: npm install -g pnpm
# If pnpm is not an option, npm works too — adjust install commands below
# 3. Global bin directory is in PATH
# For pnpm:
pnpm bin -g # prints the global bin directory
# For npm:
npm prefix -g # global prefix; bin is <prefix>/bin
# The directory printed above MUST be in your PATH.
# Test: echo \\$PATH | tr ':' '\\n'
# FIX: add the missing directory to your shell's startup file
# (e.g. ~/.bashrc, ~/.zshrc, ~/.profile, or fish config):
# export PATH="<global-bin-dir>:$PATH"
\`\`\`
**All checks must pass before continuing.** If you had to modify PATH, verify the change persists by opening a new shell or sourcing your shell config.
### Step 1 — Install @ocas/cli
\`\`\`bash
# Using pnpm (preferred)
pnpm add -g @ocas/cli
# Using npm
npm install -g @ocas/cli
\`\`\`
**After installing, verify that \`ocas\` is available in PATH:**
\`\`\`bash
ocas --version # should print ${CLI_VERSION}
\`\`\`
If the command is not found, the global bin directory is not in the current shell's PATH. **You must fix this before continuing:**
1. Find where the binary was installed:
\`\`\`bash
find ~/.local /usr/local -name ocas -type f 2>/dev/null
npm prefix -g # global prefix — bin is <prefix>/bin
\`\`\`
2. Add the directory to PATH permanently by appending to the user's shell config (e.g. \`~/.bashrc\`, \`~/.zshrc\`, \`~/.profile\`, or fish config):
\`\`\`bash
export PATH="<global-bin-dir>:$PATH"
\`\`\`
3. Source the updated config or open a new shell, then re-verify the command works.
### Step 2 — Verify version
\`\`\`bash
ocas --version # should print ${CLI_VERSION}
\`\`\`
### Step 3 — Install the ocas skill
Run the usage prompt and save it as a skill so your agent knows how to use ocas:
\`\`\`bash
ocas prompt usage
\`\`\`
The output is the skill body **without** frontmatter. Save it as a skill in your agent framework with the following YAML frontmatter prepended:
\`\`\`yaml
---
name: ocas
description: "Use when storing, retrieving, or managing typed JSON data with OCAS (Object Content Addressable Store). Covers CLI usage, variable management, templates, rendering, and pipe composition."
version: ${CLI_VERSION}
author: OCAS
license: MIT
metadata:
hermes:
tags: [cas, storage, cli, json, schema, content-addressing]
---
\`\`\`
Replace the \`version\` field with the installed CLI version (from \`ocas --version\`).
**⚠ After saving the skill, start a new session** so the agent loads the updated skill content. Skills saved in the current session are not active until the next session.
### Step 4 — End-to-end verify
\`\`\`bash
# Store a string value
ocas put @ocas/string '"hello world"'
# Get it back using the returned hash
ocas get <hash>
\`\`\`
If \`ocas get\` returns \`"hello world"\`, the setup is working.
## Scenario B: Upgrade from Previous Version
### Step 1 — Update to latest
\`\`\`bash
# Using pnpm
pnpm add -g @ocas/cli@latest
# Using npm
npm install -g @ocas/cli@latest
\`\`\`
### Step 2 — Verify version
\`\`\`bash
ocas --version # should print ${CLI_VERSION}
\`\`\`
### Step 3 — Regenerate skill
Skill content is bundled with the CLI — always regenerate after upgrading:
\`\`\`bash
ocas prompt usage # → update skill "ocas"
\`\`\`
Update the \`version\` field in the skill frontmatter to match the new CLI version.
**⚠ After updating the skill, start a new session** to load the new skill content.
### Step 4 — Verify
\`\`\`bash
ocas put @ocas/string '"upgrade test"'
ocas get <hash>
\`\`\`
## Available prompts
\`\`\`bash
ocas prompt list # list available prompt names
ocas prompt usage # CLI usage guide (skill body)
ocas prompt bootstrap # this guide
\`\`\`
`;
}
-53
View File
@@ -1,53 +0,0 @@
# OCAS Skill Setup
You are being asked to install or update the OCAS (Object Content Addressable Store) skill
so that you know how to use the `ocas` CLI.
## Steps
1. **Check if OCAS CLI is installed:**
```bash
ocas --help
```
If not installed: `bun add -g @ocas/cli`
2. **Clean up old versions of the skill** (if any exist):
- Look for any existing OCAS/ocas skill files in your skill directories
(e.g. `~/.hermes/skills/ocas/SKILL.md` or similar paths).
- If a skill exists, read its `version` from the YAML frontmatter.
- If the version matches the current CLI version (`ocas --version`), you're done — no update needed.
- If the version is older, **delete the old skill file** before proceeding.
3. **Get the usage skill content:**
```bash
ocas prompt usage
```
4. **Create a SKILL.md file** in your skill directory (e.g. `~/.hermes/skills/ocas/SKILL.md`).
The output of `ocas prompt usage` is the skill body WITHOUT frontmatter.
You must prepend the following YAML frontmatter:
```yaml
---
name: ocas
description: "Use when storing, retrieving, or managing typed JSON data with OCAS (Object Content Addressable Store). Covers CLI usage, variable management, templates, rendering, and pipe composition."
version: <VERSION>
author: OCAS
license: MIT
metadata:
hermes:
tags: [cas, storage, cli, json, schema, content-addressing]
---
```
Replace `<VERSION>` with the installed CLI version (from `ocas --version`).
5. **Verify** the skill is loadable by your agent framework.
## Notes
- The skill content is bundled with the CLI and versioned with it — always use
`ocas prompt usage` to get the content matching your installed version.
- Do NOT hand-edit the skill body. If the CLI is updated, re-run `ocas prompt setup`
and follow the steps again.
- When upgrading, always delete the old skill first to avoid stale instructions.
@@ -1,60 +1,6 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: ocas var set <name> <hash> [--tag <tag>...]"`;
exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Name must follow @scope/name format (e.g. @myapp/config)"`;
exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = `
"Usage: ocas [--home <path>] [--json] <command> [args]
All JSON commands emit a { type, value } envelope. The type is the hash of the
command's @ocas/output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (ocas_ref hashes are expanded).
Commands:
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@ocas/output/put)
get <hash> Print node as envelope (@ocas/output/get)
has <hash> Print envelope (value=boolean) (@ocas/output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@ocas/output/verify)
refs <hash> List direct ocas_ref edges (@ocas/output/refs)
walk <hash> [--format tree] Recursive traversal (@ocas/output/walk)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@ocas/output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-name> [--tag <tag>...] List hashes for a type, optionally filtered by tags (@ocas/output/list)
list-meta List meta-schema hashes (value=string[]) (@ocas/output/list-meta)
list-schema List all schema hashes (value=string[]) (@ocas/output/list-schema)
tag <target> <tag>... Apply tags/labels to a target (@ocas/output/tag)
untag <target> <tag>... Remove tags/labels from a target (@ocas/output/untag)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@ocas/output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@ocas/output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list)
var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history)
template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set)
template get <schema-hash> Get template content (value=string) (@ocas/output/template-get)
template list List all templates (@ocas/output/template-list)
template delete <schema-hash> Delete template for schema (@ocas/output/template-delete)
gc Run garbage collection (@ocas/output/gc)
Flags:
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
--json Compact JSON output
--render, -r Render output inline (equivalent to | ocas render -p)
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)
Agent: If you have not installed the ocas skill for this version (0.2.0), run \`ocas prompt setup\` and follow the instructions."
`;
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
exports[`Phase 3: Variable System > 3.1 var set creates variable 1`] = `
{
"type": "0Q5EMYK4SYSS9",
"value": {
@@ -67,7 +13,7 @@ exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
}
`;
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
exports[`Phase 3: Variable System > 3.2 var get returns variable 1`] = `
{
"type": "F5RRJTXP8Z99D",
"value": {
@@ -80,7 +26,7 @@ exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
}
`;
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
exports[`Phase 3: Variable System > 3.3 var list shows all variables 1`] = `
{
"type": "AF0XACGXHPMC1",
"value": [
@@ -140,6 +86,13 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"tags": {},
"value": "944RT37WX1PQ5",
},
{
"labels": [],
"name": "@ocas/output/export",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "3P2SFAVZXZ474",
},
{
"labels": [],
"name": "@ocas/output/gc",
@@ -168,6 +121,13 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"tags": {},
"value": "1B24CBF95Q5G6",
},
{
"labels": [],
"name": "@ocas/output/import",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "198WWJWDA6KDX",
},
{
"labels": [],
"name": "@ocas/output/list",
@@ -312,7 +272,7 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
}
`;
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
exports[`Phase 3: Variable System > 3.4 var list prefix filters by prefix 1`] = `
{
"type": "AF0XACGXHPMC1",
"value": [
@@ -327,7 +287,7 @@ exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
}
`;
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
exports[`Phase 3: Variable System > 3.5 var set upsert updates existing variable 1`] = `
{
"type": "0Q5EMYK4SYSS9",
"value": {
@@ -340,7 +300,7 @@ exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1
}
`;
exports[`Phase 3: Variable System 3.6 var set with tag and label adds them 1`] = `
exports[`Phase 3: Variable System > 3.6 var set with tag and label adds them 1`] = `
{
"type": "0Q5EMYK4SYSS9",
"value": {
@@ -357,7 +317,7 @@ exports[`Phase 3: Variable System 3.6 var set with tag and label adds them 1`] =
}
`;
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
exports[`Phase 3: Variable System > 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "AF0XACGXHPMC1",
"value": [
@@ -376,7 +336,7 @@ exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag
}
`;
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
exports[`Phase 3: Variable System > 3.8 var list --tag important filters by label 1`] = `
{
"type": "AF0XACGXHPMC1",
"value": [
@@ -395,7 +355,7 @@ exports[`Phase 3: Variable System 3.8 var list --tag important filters by label
}
`;
exports[`Phase 3: Variable System 3.9 var set without label removes it 1`] = `
exports[`Phase 3: Variable System > 3.9 var set without label removes it 1`] = `
{
"type": "0Q5EMYK4SYSS9",
"value": {
@@ -410,7 +370,7 @@ exports[`Phase 3: Variable System 3.9 var set without label removes it 1`] = `
}
`;
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
exports[`Phase 3: Variable System > 3.10 var delete removes variable 1`] = `
{
"type": "C3MYPR5RGQFZT",
"value": [
@@ -427,9 +387,9 @@ exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
}
`;
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=@myapp/config, schema=FRBAB1BF0ZBCS"`;
exports[`Phase 3: Variable System > 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=@myapp/config, schema=FRBAB1BF0ZBCS"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
exports[`Phase 4: Template System > 4.1 template set registers template 1`] = `
{
"type": "BJDHPAE4Q8TXM",
"value": {
@@ -439,14 +399,14 @@ exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
}
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
exports[`Phase 4: Template System > 4.2 template get returns template text 1`] = `
{
"type": "0B0HBHZGYHR84",
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
}
`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
exports[`Phase 4: Template System > 4.3 template list shows registered templates 1`] = `
{
"type": "8917JQTD1R5JF",
"value": [
@@ -458,7 +418,7 @@ exports[`Phase 4: Template System 4.3 template list shows registered templates 1
}
`;
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
exports[`Phase 4: Template System > 4.4 template delete removes template 1`] = `
{
"type": "BY7BGZJND3N7R",
"value": {
@@ -467,4 +427,63 @@ exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
}
`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: FRBAB1BF0ZBCS"`;
exports[`Phase 4: Template System > 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: FRBAB1BF0ZBCS"`;
exports[`Phase 7: Edge Cases > 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
exports[`Phase 7: Edge Cases > 7.3 var set empty name errors 1`] = `"Usage: ocas var set <name> <hash> [--tag <tag>...]"`;
exports[`Phase 7: Edge Cases > 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Name must follow @scope/name format (e.g. @myapp/config)"`;
exports[`Phase 7: Edge Cases > 7.5 no subcommand shows help text 1`] = `
"Usage: ocas [--home <path>] [--json] <command> [args]
All JSON commands emit a { type, value } envelope. The type is the hash of the
command's @ocas/output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (ocas_ref hashes are expanded).
Commands:
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@ocas/output/put)
get <hash> Print node as envelope (@ocas/output/get)
has <hash> Print envelope (value=boolean) (@ocas/output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@ocas/output/verify)
refs <hash> List direct ocas_ref edges (@ocas/output/refs)
walk <hash> [--format tree] Recursive traversal (@ocas/output/walk)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@ocas/output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-name> [--tag <tag>...] List hashes for a type, optionally filtered by tags (@ocas/output/list)
list-meta List meta-schema hashes (value=string[]) (@ocas/output/list-meta)
list-schema List all schema hashes (value=string[]) (@ocas/output/list-schema)
tag <target> <tag>... Apply tags/labels to a target (@ocas/output/tag)
untag <target> <tag>... Remove tags/labels from a target (@ocas/output/untag)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@ocas/output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@ocas/output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list)
var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history)
template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set)
template get <schema-hash> Get template content (value=string) (@ocas/output/template-get)
template list List all templates (@ocas/output/template-list)
template delete <schema-hash> Delete template for schema (@ocas/output/template-delete)
gc Run garbage collection (@ocas/output/gc)
export <root>... -o <file> Export CAS closure of roots to a tar bundle
import <bundle> [--scope @s] Import nodes/vars/tags from a bundle into the store
Flags:
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
--store <bundle> Use a tar bundle as a read-only store (instead of --home)
--json Compact JSON output
--render, -r Render output inline (equivalent to | ocas render -p)
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)
--scope <name> Variable name remap target for import (e.g. --scope @imported)
-o <file> Output path for export
Agent: If you have not installed the ocas skill for this version (0.4.0), run \`ocas prompt bootstrap\` and follow the instructions."
`;
@@ -1,6 +1,6 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
exports[`Phase 1: CAS Core > 1.6 get returns node JSON (snapshot) 1`] = `
{
"type": "7V5G8E2VW8B2G",
"value": {
@@ -1,5 +1,5 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render > 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render > 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
@@ -1,3 +1,3 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
exports[`Phase 2: Schema Validation > 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
@@ -1,20 +1,20 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
exports[`Phase 1: CAS Core > 1.9 verify returns ok for valid node 1`] = `
{
"type": "52HEFB52BD0GF",
"value": "ok",
}
`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
exports[`Phase 1: CAS Core > 1.10 refs lists direct references (snapshot) 1`] = `
"{
"type": "2TKP4RGBJ4V43",
"value": []
}"
`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
exports[`Phase 1: CAS Core > 1.11 walk shows traversal tree (snapshot) 1`] = `
"{
"type": "4HG6MD3XG5H5C",
"value": [
+24 -24
View File
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
// ---- @ Alias Resolution Tests ----
@@ -16,7 +17,7 @@ beforeEach(() => {
`ocas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "../src/index.ts");
cliPath = join(import.meta.dirname, "../dist/index.js");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
@@ -34,31 +35,30 @@ afterEach(() => {
/**
* Run CLI command and return stdout, stderr, and exit code
*/
async function runCliAlias(...args: string[]): Promise<{
function runCliAlias(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--home", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
/** Extract the `value` field from a { type, value } envelope JSON string. */
+102 -55
View File
@@ -1,35 +1,33 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
const pkgPath = resolve(import.meta.dir, "../package.json");
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
const pkgPath = resolve(import.meta.dirname, "../package.json");
// --- ocas command alias tests (from cli.test.ts) ---
describe("ocas binary", () => {
test("T1: ocas bin entry exists in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ocas).toBe("src/index.ts");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
expect(pkg.bin.ocas).toBe("dist/index.js");
});
test("T2: no legacy bin entries (json-cas, ucas)", async () => {
const pkg = await Bun.file(pkgPath).json();
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
expect(pkg.bin["json-cas"]).toBeUndefined();
expect(pkg.bin.ucas).toBeUndefined();
expect(Object.keys(pkg.bin)).toEqual(["ocas"]);
});
test("T3: ocas command is executable and shows help", async () => {
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
stdout: "pipe",
stderr: "pipe",
test("T3: ocas command is executable and shows help", () => {
const stdout = execFileSync("node", [entrypoint, "--help"], {
encoding: "utf-8",
timeout: 10000,
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
});
@@ -41,17 +39,31 @@ describe("Phase 7: Edge Cases", () => {
let typeHash: string;
let nodeHash: string;
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
beforeAll(async () => {
@@ -125,17 +137,24 @@ describe("Phase 7: Edge Cases", () => {
expect(combined.toLowerCase()).toContain("usage");
});
test("7.6 --home path is a file errors", async () => {
test("7.6 --home path is a file errors", () => {
const fileAsStore = join(tmpStore, "not-a-directory");
writeFileSync(fileAsStore, "test");
const proc = Bun.spawn(
["bun", entrypoint, "--home", fileAsStore, "get", "AAAAAAAAAAAAA"],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stderr = (await new Response(proc.stderr).text()).trim();
expect(exitCode).not.toBe(0);
expect(stderr).toContain("not a directory");
try {
execFileSync(
"node",
[entrypoint, "--home", fileAsStore, "get", "AAAAAAAAAAAAA"],
{
encoding: "utf-8",
timeout: 10000,
},
);
expect.unreachable("should have thrown");
} catch (e: unknown) {
const err = e as { stderr?: string; status?: number };
expect(err.status).not.toBe(0);
expect((err.stderr ?? "").trim()).toContain("not a directory");
}
});
});
@@ -146,17 +165,31 @@ describe("Phase 3: Variable System", () => {
let typeHash: string;
let nodeHash: string;
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
beforeAll(async () => {
@@ -335,17 +368,31 @@ describe("Phase 4: Template System", () => {
let tmpStore: string;
let typeHash: string;
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
beforeAll(async () => {
+263
View File
@@ -0,0 +1,263 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { envValue, runCli } from "./helpers";
let storePath: string;
let bundlePath: string;
beforeEach(() => {
storePath = mkdtempSync(join(tmpdir(), "ocas-export-import-"));
bundlePath = join(storePath, "bundle.tar");
});
afterEach(() => {
rmSync(storePath, { recursive: true, force: true });
});
async function setupSampleStore(): Promise<{
schemaHash: string;
nodeHash: string;
}> {
// Create a schema, a node, a variable, and a tag.
const { openStore } = await import("@ocas/fs");
const { putSchema } = await import("@ocas/core");
const store = await openStore(storePath);
const schemaHash = putSchema(store, {
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
});
const nodeHash = store.cas.put(schemaHash, { name: "Alice", age: 30 });
store.var.set("@test/app", nodeHash);
store.tag.tag(nodeHash, [{ op: "set", key: "env", value: "prod" }]);
return { schemaHash, nodeHash };
}
describe("CLI export/import", () => {
test("3.1 export: basic usage with -o flag", async () => {
const { nodeHash } = await setupSampleStore();
const { exitCode, stdout } = runCli(
["export", "@test/app", "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(0);
expect(existsSync(bundlePath)).toBe(true);
const value = envValue(stdout) as {
nodes: number;
vars: number;
tags: number;
};
expect(value.nodes).toBeGreaterThan(0);
expect(value.vars).toBeGreaterThanOrEqual(1);
expect(value.tags).toBeGreaterThanOrEqual(1);
void nodeHash;
});
test("3.2 export: multiple roots", async () => {
const { openStore } = await import("@ocas/fs");
const { putSchema } = await import("@ocas/core");
const store = await openStore(storePath);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "a");
const bHash = store.cas.put(schemaHash, "b");
store.var.set("@test/a", aHash);
store.var.set("@test/b", bHash);
const { exitCode } = runCli(
["export", "@test/a", "@test/b", "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(0);
expect(existsSync(bundlePath)).toBe(true);
});
test("3.3 export: hash as root", async () => {
const { nodeHash } = await setupSampleStore();
const { exitCode } = runCli(
["export", nodeHash, "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(0);
});
test("3.4 export: missing root → error", async () => {
await setupSampleStore();
const { exitCode, stderr } = runCli(
["export", "@test/nonexistent", "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(1);
expect(stderr.length).toBeGreaterThan(0);
});
test("3.5 export: missing -o flag → error", async () => {
await setupSampleStore();
const { exitCode, stderr } = runCli(["export", "@test/app"], storePath);
expect(exitCode).toBe(1);
expect(stderr).toMatch(/-o|output/i);
});
test("3.6 import: basic", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-dst-"));
try {
const { exitCode, stdout } = runCli(["import", bundlePath], dstPath);
expect(exitCode).toBe(0);
const stats = envValue(stdout) as {
nodes: { imported: number; skipped: number };
vars: { created: number; updated: number };
tags: number;
};
expect(stats.nodes.imported).toBeGreaterThan(0);
// Variable accessible in dst.
const get = runCli(["get", "@test/app"], dstPath);
expect(get.exitCode).toBe(0);
} finally {
rmSync(dstPath, { recursive: true, force: true });
}
});
test("3.7 import --scope remaps variables", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-scope-"));
try {
const { exitCode } = runCli(
["import", bundlePath, "--scope", "@imported"],
dstPath,
);
expect(exitCode).toBe(0);
const list = runCli(["var", "list", "@imported"], dstPath);
expect(list.exitCode).toBe(0);
const variables = envValue(list.stdout) as Array<{ name: string }>;
expect(variables.some((v) => v.name === "@imported/app")).toBe(true);
} finally {
rmSync(dstPath, { recursive: true, force: true });
}
});
test("3.8 import is idempotent", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-idem-"));
try {
runCli(["import", bundlePath], dstPath);
const second = runCli(["import", bundlePath], dstPath);
expect(second.exitCode).toBe(0);
const stats = envValue(second.stdout) as {
nodes: { imported: number; skipped: number };
};
expect(stats.nodes.imported).toBe(0);
expect(stats.nodes.skipped).toBeGreaterThan(0);
} finally {
rmSync(dstPath, { recursive: true, force: true });
}
});
test("3.9 --store flag: ocas get reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode, stdout } = runCli([
"get",
nodeHash,
"--store",
bundlePath,
]);
expect(exitCode).toBe(0);
const value = envValue(stdout) as { payload: { name: string } };
expect(value.payload.name).toBe("Alice");
});
test("3.10 --store flag: ocas var list reads from bundle", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode, stdout } = runCli([
"var",
"list",
"@test",
"--store",
bundlePath,
]);
expect(exitCode).toBe(0);
const variables = envValue(stdout) as Array<{ name: string }>;
expect(variables.some((v) => v.name === "@test/app")).toBe(true);
});
test("3.11 --store flag: ocas walk reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode, stdout } = runCli([
"walk",
nodeHash,
"--store",
bundlePath,
]);
expect(exitCode).toBe(0);
const hashes = envValue(stdout) as string[];
expect(hashes).toContain(nodeHash);
});
test("3.12 --store flag: ocas refs reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode } = runCli(["refs", nodeHash, "--store", bundlePath]);
expect(exitCode).toBe(0);
});
test("3.13 --store flag: ocas has reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const present = runCli(["has", nodeHash, "--store", bundlePath]);
expect(present.exitCode).toBe(0);
expect(envValue(present.stdout)).toBe(true);
const missing = runCli(["has", "AAAAAAAAAAAAA", "--store", bundlePath]);
expect(missing.exitCode).toBe(0);
expect(envValue(missing.stdout)).toBe(false);
});
test("3.14 --store flag: write commands fail with 'read-only' error", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
// Try `ocas put` against a bundle.
const tmp = mkdtempSync(join(tmpdir(), "ocas-store-write-"));
try {
const payload = join(tmp, "p.json");
writeFileSync(payload, JSON.stringify({ name: "X" }));
const { exitCode, stderr } = runCli([
"put",
"@ocas/string",
payload,
"--store",
bundlePath,
]);
expect(exitCode).toBe(1);
expect(stderr).toMatch(/read[- ]only/i);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
// Suppress unused.
void mkdirSync;
+135 -24
View File
@@ -1,10 +1,11 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
let tmpStore: string;
let typeHash: string;
@@ -41,17 +42,30 @@ afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
// ---- Phase 6: GC ----
@@ -71,19 +85,19 @@ describe("Phase 6: GC", () => {
);
});
test("6.2 gc | render -p renders the gc stats", async () => {
const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]);
test("6.2 gc | render -p renders the gc stats", () => {
const { stdout: gcOut, exitCode: gcExit } = runCli(["gc"]);
expect(gcExit).toBe(0);
const proc = Bun.spawn(
["bun", entrypoint, "--home", tmpStore, "render", "--pipe"],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(gcOut);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
expect(exitCode).toBe(0);
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, "render", "--pipe"],
{
input: gcOut,
encoding: "utf-8",
timeout: 10000,
},
).trim();
// gc value is an object { total, reachable, collected, scanned }
expect(stdout).toContain("total:");
});
@@ -109,3 +123,100 @@ describe("Phase 6: GC", () => {
expect(envValue(afterGc)).toBe(false);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #93: gc must not collect uwf-style step chains joined by oneOf prev
// ──────────────────────────────────────────────────────────────────────────────
describe("GC #93 - oneOf step chain CLI integration", () => {
test("6.5 gc preserves step chain joined by oneOf prev", async () => {
const subStore = mkdtempSync(join(tmpdir(), "ocas-e2e-93-"));
try {
const { openStore: openFsStore } = await import("@ocas/fs");
const { putSchema } = await import("@ocas/core");
const store = await openFsStore(subStore);
const stepSchemaHash = putSchema(store, {
type: "object",
properties: {
payload: { type: "string" },
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const step1File = join(subStore, "step1.json");
writeFileSync(step1File, JSON.stringify({ payload: "a", prev: null }));
const runCliSub = (
args: string[],
): { stdout: string; stderr: string; exitCode: number } => {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", subStore, ...args],
{ encoding: "utf-8", timeout: 10000 },
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as {
stdout?: string;
stderr?: string;
status?: number;
};
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
};
const { stdout: s1Out } = runCliSub(["put", stepSchemaHash, step1File]);
const step1Hash = envValue(s1Out) as string;
const step2File = join(subStore, "step2.json");
writeFileSync(
step2File,
JSON.stringify({ payload: "b", prev: step1Hash }),
);
const { stdout: s2Out } = runCliSub(["put", stepSchemaHash, step2File]);
const step2Hash = envValue(s2Out) as string;
const step3File = join(subStore, "step3.json");
writeFileSync(
step3File,
JSON.stringify({ payload: "c", prev: step2Hash }),
);
const { stdout: s3Out } = runCliSub(["put", stepSchemaHash, step3File]);
const step3Hash = envValue(s3Out) as string;
const orphanFile = join(subStore, "orphan-step.json");
writeFileSync(
orphanFile,
JSON.stringify({ payload: "orphan", prev: null }),
);
const { stdout: orphanOut } = runCliSub([
"put",
stepSchemaHash,
orphanFile,
]);
const orphanHash = envValue(orphanOut) as string;
runCliSub(["var", "set", "@test/thread/head", step3Hash]);
const { exitCode } = runCliSub(["gc"]);
expect(exitCode).toBe(0);
const { stdout: has1 } = runCliSub(["has", step1Hash]);
expect(envValue(has1)).toBe(true);
const { stdout: has2 } = runCliSub(["has", step2Hash]);
expect(envValue(has2)).toBe(true);
const { stdout: has3 } = runCliSub(["has", step3Hash]);
expect(envValue(has3)).toBe(true);
const { stdout: hasOrphan } = runCliSub(["has", orphanHash]);
expect(envValue(hasOrphan)).toBe(false);
} finally {
rmSync(subStore, { recursive: true, force: true });
}
});
});
+24 -18
View File
@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash } from "@ocas/core";
import { bootstrap, validate } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
let testDir: string;
let storePath: string;
@@ -16,7 +17,7 @@ beforeEach(() => {
`ocas-get-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "../src/index.ts");
cliPath = join(import.meta.dirname, "../dist/index.js");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
@@ -29,25 +30,30 @@ afterEach(() => {
}
});
async function runCli(...args: string[]): Promise<{
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--home", storePath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
async function createTestNode(value = "hello-world"): Promise<Hash> {
+43 -28
View File
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
@@ -22,8 +23,8 @@ export {
writeFileSync,
};
export const entrypoint = resolve(import.meta.dir, "../src/index.ts");
export const pkgPath = resolve(import.meta.dir, "../package.json");
export const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
export const pkgPath = resolve(import.meta.dirname, "../package.json");
/** Extract the `value` field from a { type, value } envelope JSON string. */
export function envValue(json: string): unknown {
@@ -46,44 +47,58 @@ export async function putSchemaFile(
return hash;
}
const quietEnv = { ...process.env, NODE_NO_WARNINGS: "1" };
/**
* Run CLI command. Accepts either a string[] or ...string[] (rest args).
* If first arg is an array, uses that as args. Otherwise treats all args as the command.
*/
export async function runCli(
export function runCli(
args: string[],
storePath?: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
): { stdout: string; stderr: string; exitCode: number } {
const finalArgs = storePath
? ["bun", entrypoint, "--home", storePath, ...args]
: ["bun", entrypoint, ...args];
const proc = Bun.spawn(finalArgs, {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { stdout, stderr, exitCode };
? [entrypoint, "--home", storePath, ...args]
: [entrypoint, ...args];
try {
const stdout = execFileSync("node", finalArgs, {
encoding: "utf-8",
timeout: 10000,
env: quietEnv,
});
return { stdout, stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
exitCode: err.status ?? 1,
};
}
}
export async function runCliWithStdin(
export function runCliWithStdin(
args: string[],
storePath: string,
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const finalArgs = ["bun", entrypoint, "--home", storePath, ...args];
const proc = Bun.spawn(finalArgs, {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
});
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { stdout, stderr, exitCode };
): { stdout: string; stderr: string; exitCode: number } {
const finalArgs = [entrypoint, "--home", storePath, ...args];
try {
const stdout = execFileSync("node", finalArgs, {
input: stdin,
encoding: "utf-8",
timeout: 10000,
env: quietEnv,
});
return { stdout, stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
exitCode: err.status ?? 1,
};
}
}
/**
+1 -1
View File
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BOOTSTRAP_STORE } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { envValue, runCli } from "./helpers.js";
let storePath: string;
+11 -18
View File
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { envValue, runCli } from "./helpers.js";
const HASH_RE = /^[0-9A-HJKMNP-TV-Z]{13}$/;
@@ -18,24 +19,16 @@ afterEach(() => {
});
async function putString(text: string): Promise<string> {
// Use the @ocas/string built-in via library; CLI doesn't expose put-text but
// `put @ocas/string --pipe` works. We'll use --pipe with stdin.
const proc = Bun.spawn(
[
"bun",
join(import.meta.dir, "../src/index.ts"),
"--home",
storePath,
"put",
"@ocas/string",
"--pipe",
],
{ stdout: "pipe", stderr: "pipe", stdin: "pipe" },
const entrypoint = join(import.meta.dirname, "../dist/index.js");
const out = execFileSync(
"node",
[entrypoint, "--home", storePath, "put", "@ocas/string", "--pipe"],
{
input: JSON.stringify(text),
encoding: "utf-8",
timeout: 10000,
},
);
proc.stdin.write(JSON.stringify(text));
proc.stdin.end();
await proc.exited;
const out = await new Response(proc.stdout).text();
return (JSON.parse(out) as { value: string }).value;
}
+39 -40
View File
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
let testDir: string;
let storePath: string;
@@ -13,7 +14,7 @@ beforeEach(() => {
`ocas-list-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "../src/index.ts");
cliPath = join(import.meta.dirname, "../dist/index.js");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
@@ -26,50 +27,48 @@ afterEach(() => {
}
});
async function runCli(...args: string[]): Promise<{
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--home", storePath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
async function putString(value: string): Promise<string> {
const proc = Bun.spawn(
[
"bun",
"run",
cliPath,
"--home",
storePath,
"put",
"@ocas/string",
"--pipe",
],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(JSON.stringify(value));
await proc.stdin.end();
const out = await new Response(proc.stdout).text();
const err = await new Response(proc.stderr).text();
await proc.exited;
if ((proc.exitCode ?? 0) !== 0) {
throw new Error(`put failed: ${err}`);
function putString(value: string): string {
try {
const out = execFileSync(
"node",
[cliPath, "--home", storePath, "put", "@ocas/string", "--pipe"],
{
input: JSON.stringify(value),
encoding: "utf-8",
timeout: 10000,
},
);
return JSON.parse(out.trim()).value as string;
} catch (e: unknown) {
const err = e as { stderr?: string };
throw new Error(`put failed: ${err.stderr ?? ""}`);
}
return JSON.parse(out.trim()).value as string;
}
async function tag(target: string, ...tagSpecs: string[]): Promise<void> {
+48 -26
View File
@@ -1,10 +1,11 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
let tmpStore: string;
let typeHash: string;
@@ -43,34 +44,55 @@ afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
async function runCliWithStdin(
function runCliWithStdin(
args: string[],
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
input: stdin,
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
// ---- Phase 8: Pipe Composition ----
+26 -13
View File
@@ -1,10 +1,11 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
let tmpStore: string;
let typeHash: string;
@@ -41,17 +42,29 @@ afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
function runCli(args: string[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
describe("Phase 1: CAS Core", () => {
+46 -24
View File
@@ -1,12 +1,13 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { bootstrap } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue, putSchemaFile, runCli, runCliWithStdin } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
// --- Standalone render tests from cli.test.ts ---
@@ -57,34 +58,55 @@ describe("Phase 5: Render", () => {
let typeHash: string;
let nodeHash: string;
async function runCliE2e(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
function runCliE2e(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
async function _runCliE2eWithStdin(
args: string[],
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
input: stdin,
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
beforeAll(async () => {
+47 -21
View File
@@ -1,7 +1,8 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue, putSchemaFile, runCli } from "./helpers";
// ---- Issue #50: Schema Validation in put Command ----
@@ -559,7 +560,7 @@ describe("Phase 2: Schema Validation", () => {
let typeHash: string;
let _nodeHash: string;
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
beforeAll(async () => {
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
@@ -581,12 +582,14 @@ describe("Phase 2: Schema Validation", () => {
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const proc = Bun.spawn(
["bun", entrypoint, "--home", tmpStore, "put", typeHash, nodeFile],
{ stdout: "pipe", stderr: "pipe" },
);
await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, "put", typeHash, nodeFile],
{
encoding: "utf-8",
timeout: 10000,
},
).trim();
_nodeHash = envValue(stdout) as string;
});
@@ -597,13 +600,25 @@ describe("Phase 2: Schema Validation", () => {
test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => {
const badFile = join(tmpStore, "bad-node.json");
writeFileSync(badFile, JSON.stringify({ name: 123 }));
const proc = Bun.spawn(
["bun", entrypoint, "--home", tmpStore, "put", typeHash, badFile],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
let stdout = "",
stderr = "",
exitCode = 0;
try {
stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, "put", typeHash, badFile],
{
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
},
).trim();
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
stdout = (err.stdout ?? "").trim();
stderr = (err.stderr ?? "").trim();
exitCode = err.status ?? 1;
}
expect(exitCode).not.toBe(0);
expect(stdout).toBe("");
expect(stderr).toContain("Validation failed");
@@ -612,12 +627,23 @@ describe("Phase 2: Schema Validation", () => {
test("2.3 put against non-existent schema hash fails", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const proc = Bun.spawn(
["bun", entrypoint, "--home", tmpStore, "put", "AAAAAAAAAAAAA", nodeFile],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stderr = (await new Response(proc.stderr).text()).trim();
let exitCode = 0,
stderr = "";
try {
execFileSync(
"node",
[entrypoint, "--home", tmpStore, "put", "AAAAAAAAAAAAA", nodeFile],
{
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
},
);
} catch (e: unknown) {
const err = e as { stderr?: string; status?: number };
stderr = (err.stderr ?? "").trim();
exitCode = err.status ?? 1;
}
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
+24 -18
View File
@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash } from "@ocas/core";
import { bootstrap } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
let testDir: string;
let storePath: string;
@@ -16,7 +17,7 @@ beforeEach(() => {
`ocas-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "../src/index.ts");
cliPath = join(import.meta.dirname, "../dist/index.js");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
@@ -29,25 +30,30 @@ afterEach(() => {
}
});
async function runCli(...args: string[]): Promise<{
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--home", storePath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
async function createTestNode(): Promise<Hash> {
+24 -24
View File
@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
import { bootstrap } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
// ---- Test helpers ----
@@ -19,7 +20,7 @@ beforeEach(() => {
`ocas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "../src/index.ts");
cliPath = join(import.meta.dirname, "../dist/index.js");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
@@ -37,31 +38,30 @@ afterEach(() => {
/**
* Run CLI command and return stdout, stderr, and exit code
*/
async function runCli(...args: string[]): Promise<{
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--home", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
/**
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
const usagePath = join(import.meta.dir, "usage.md");
const usagePath = join(import.meta.dirname, "..", "prompts", "usage.md");
describe("usage.md doc cleanup (D)", () => {
test("D3. usage.md does not reference legacy openStoreAndVarStore / createVariableStore", () => {
+24 -24
View File
@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
import { bootstrap } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
let testDir: string;
let storePath: string;
@@ -16,7 +17,7 @@ beforeEach(() => {
`ocas-history-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "../src/index.ts");
cliPath = join(import.meta.dirname, "../dist/index.js");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
@@ -30,31 +31,30 @@ afterEach(() => {
}
});
async function runCli(...args: string[]): Promise<{
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--home", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
async function setupSchemaAndValues(): Promise<{
+29 -41
View File
@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
import { bootstrap, putSchema } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
// ---- Test helpers ----
@@ -19,7 +20,7 @@ beforeEach(() => {
`ocas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "../src/index.ts");
cliPath = join(import.meta.dirname, "../dist/index.js");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
@@ -37,31 +38,30 @@ afterEach(() => {
/**
* Run CLI command and return stdout, stderr, and exit code
*/
async function runCli(...args: string[]): Promise<{
function runCli(...rawArgs: (string | string[])[]): {
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--home", storePath, ...args],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
} {
const args = rawArgs.flat();
try {
const stdout = execFileSync(
"node",
[cliPath, "--home", storePath, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
/**
@@ -783,26 +783,14 @@ describe("global options", () => {
const hash = await createTestNode(store, typeHash, { test: "data" });
// Override with custom store path
const proc = Bun.spawn(
[
"bun",
"run",
cliPath,
"--home",
customStorePath,
"var",
"set",
"@test/x",
hash,
],
execFileSync(
"node",
[cliPath, "--home", customStorePath, "var", "set", "@test/x", hash],
{
stdout: "pipe",
stderr: "pipe",
encoding: "utf-8",
timeout: 10000,
},
);
await proc.exited;
expect(proc.exitCode).toBe(0);
});
});
+26 -13
View File
@@ -1,10 +1,11 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { envValue, stripVolatile } from "./helpers";
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
const entrypoint = resolve(import.meta.dirname, "../dist/index.js");
let tmpStore: string;
let typeHash: string;
@@ -38,17 +39,29 @@ afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
function runCli(args: string[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
try {
const stdout = execFileSync(
"node",
[entrypoint, "--home", tmpStore, ...args],
{
encoding: "utf-8",
timeout: 10000,
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; status?: number };
return {
stdout: (err.stdout ?? "").trim(),
stderr: (err.stderr ?? "").trim(),
exitCode: err.status ?? 1,
};
}
}
describe("Phase 1: CAS Core", () => {
+5 -5
View File
@@ -1,10 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": false,
"declaration": false,
"declarationMap": false,
"noEmit": true
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"],
"references": [{ "path": "../core" }, { "path": "../fs" }]
}
+37 -25
View File
@@ -1,40 +1,52 @@
# @ocas/core
## 0.2.0
## 0.4.1 — 2026-06-07
- Fix `gc` failing to preserve nodes referenced via `oneOf``collectRefs()` now traverses `oneOf` branches alongside existing `anyOf`/`allOf`/`if-then-else` handling.
## 0.4.0 — 2026-06-07
- New `computeClosure(store, roots)` — traverses references and schema chains to gather a complete CAS closure.
- New `exportBundle()` / `importBundle()` / `loadBundleStore()` — produce and consume self-contained POSIX-tar bundles (`cas/*.bin` CBOR payloads, `vars.jsonl`, `tags.jsonl`).
- New builtin output schemas: `@ocas/output/export`, `@ocas/output/import`.
## 0.3.0 — 2026-06-03
- No API changes. Coordinated version bump with `@ocas/fs` 0.3.0.
## 0.2.2 — 2026-06-03
- Lint and format fixes.
## 0.2.1 — 2026-06-03
- Migrate runtime from Bun to Node.js + pnpm.
- Migrate test framework from `bun:test` to Vitest.
- Extract `VariableStore` SQLite implementation to `@ocas/fs`.
## 0.2.0 — 2026-06-02
### Breaking Changes
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties
- `bootstrap(store)` and `putSchema(store, schema)` are now **synchronous** (were async)
- `VariableStore` class removed from `@ocas/core` — SQLite implementation moved to `@ocas/fs`
- `createVariableStore()` removed from `@ocas/core`
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally
- `@ocas/core` has zero `bun:sqlite` imports — pure TypeScript
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties.
- `bootstrap(store)` and `putSchema(store, schema)` are now synchronous.
- `VariableStore` class removed — SQLite implementation moved to `@ocas/fs`.
- `createVariableStore()` removed.
- Zero `bun:sqlite` imports — pure TypeScript.
### New Features
- `CasStore`, `VarStore`, `TagStore` sub-store types
- `TagStore` — first-class tags on any CAS node (not just variables)
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands
- `ocas get` and `ocas var get` now include tag info in output
- `ocas list --tag` and `ocas var list --tag` filter support
- `var-store-helpers.ts` — shared validation/history logic
- `validation.ts` — shared `validateName()` exported from core
- `CasStore`, `VarStore`, `TagStore` sub-store types.
- `validation.ts` — shared `validateName()` exported from core.
## 0.1.2
### Patch Changes
## 0.1.2 — 2026-06-02
- Fix TypeScript LSP errors: tsconfig `noEmit`, `exactOptionalPropertyTypes` conditional spread, `schemaHash` scope.
## 0.1.1
## 0.1.1 — 2026-06-02
### Patch Changes
- Internal improvements.
- Internal improvements for v0.1.1 release.
## 0.1.0 — 2026-06-01
## 0.1.0
Initial release as `@ocas/core`. Content-addressable store engine with JSON Schema typed nodes, XXH64 hashing, variable store, render with resolution decay, and LiquidJS template support.
Initial release. Content-addressable store engine with JSON Schema typed nodes, XXH64 hashing, variable store, render with resolution decay, and LiquidJS template support.
+48 -9
View File
@@ -6,14 +6,14 @@ Core CAS engine — hashing, schema, store, verify, bootstrap.
`@ocas/core` is the foundation of the ocas monorepo. It defines content-addressed nodes (`CasNode`), the `Store` interface, XXH64-based hashing with deterministic CBOR, JSON Schema registration and validation (including `cas_ref` links between nodes), bootstrap seeding, and integrity verification.
Other packages build on this layer: `ocas-fs` provides persistence, and `cli-ocas` exposes store operations on the command line.
Other packages build on this layer: `@ocas/fs` provides persistence, and `@ocas/cli` exposes store operations on the command line.
**Dependencies:** `ajv`, `cborg`, `xxhash-wasm`
**Dependencies:** `ajv`, `cborg`, `liquidjs`, `xxhash-wasm`
## Installation
```bash
bun add @ocas/core
pnpm add @ocas/core
```
## API
@@ -32,12 +32,7 @@ type CasNode<T = unknown> = {
timestamp: number; // Unix epoch ms
};
type Store = {
put(typeHash: Hash, payload: unknown): Promise<Hash>;
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
listByType(typeHash: Hash): Hash[];
};
type Store = { cas: CasStore; var: VarStore; tag: TagStore; };
type JSONSchema = Record<string, unknown>;
@@ -104,6 +99,48 @@ async function verify(hash: Hash, node: CasNode): Promise<boolean>;
Recomputes hash from `node` and compares to `hash` (self-referencing vs normal rules).
### Closure & Bundles
```typescript
type ClosureResult = {
nodes: Set<Hash>;
vars: Variable[];
tags: Map<Hash, Tag[]>;
};
function computeClosure(store: Store, roots: Hash[]): ClosureResult;
type ExportStats = { nodes: number; vars: number; tags: number };
type ImportOptions = { scope?: string };
type ImportStats = {
nodes: { imported: number; skipped: number };
vars: { created: number; updated: number };
tags: number;
};
async function exportBundle(
store: Store,
roots: Hash[],
outputPath: string,
): Promise<ExportStats>;
async function importBundle(
bundlePath: string,
target: Store,
options?: ImportOptions,
): Promise<ImportStats>;
async function loadBundleStore(bundlePath: string): Promise<Store>;
```
- `computeClosure` — walks `cas_ref` edges and schema chains from each root,
also gathering every `Variable` whose `value` lands in the closure and every
`Tag` attached to an in-closure target.
- `exportBundle` — writes a self-contained POSIX-tar archive containing
`cas/<hash>.bin` (CBOR-encoded payloads), `vars.jsonl`, and `tags.jsonl`.
- `importBundle` — content-addressed merge into `target`. Idempotent:
re-importing the same bundle yields zero `imported` and zero `created`.
`options.scope` rewrites the leading `@scope/` of every imported variable
name except `@ocas/*` builtins.
- `loadBundleStore` — convenience that returns an in-memory `Store` populated
from a bundle (for read-only inspection without touching the persistent store).
### Example
```typescript
@@ -154,6 +191,8 @@ walk(store, bobHash, (h) => console.log(h)); // bobHash, aliceHash
| `mem-store.ts` | Alternate in-memory store (tests only; not exported) |
| `schema.ts` | Schema put/get/validate, `refs`, `walk` |
| `verify.ts` | Node integrity verification |
| `closure.ts` | `computeClosure` — refs + schema chain traversal |
| `bundle.ts` | `exportBundle`, `importBundle`, `loadBundleStore` (POSIX tar) |
| `index.ts` | Public exports |
Tests live in `src/*.test.ts` and `tests/`.
+14 -5
View File
@@ -1,6 +1,16 @@
{
"name": "@ocas/core",
"version": "0.2.0",
"version": "0.4.1",
"description": "Core CAS engine — hashing, schema, store, verify, bootstrap",
"keywords": [
"cas",
"content-addressing",
"json-schema",
"typescript"
],
"engines": {
"node": ">=22.5.0"
},
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -14,10 +24,6 @@
"dist",
"src"
],
"scripts": {
"test": "bun test",
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
},
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
@@ -32,5 +38,8 @@
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/core",
"bugs": {
"url": "https://github.com/shazhou-ww/ocas/issues"
},
"devDependencies": {
"@types/node": "^25.9.1"
}
}
+6 -4
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import type { JSONSchema } from "./schema.js";
import { getSchema } from "./schema.js";
@@ -27,6 +27,8 @@ const OUTPUT_ALIASES = [
"@ocas/output/template-list",
"@ocas/output/template-delete",
"@ocas/output/gc",
"@ocas/output/export",
"@ocas/output/import",
] as const;
// ──────────────────────────────────────────────────────────────────────────────
@@ -34,11 +36,11 @@ const OUTPUT_ALIASES = [
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of 31 built-in schema aliases to hashes", async () => {
test("should return map of 33 built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = bootstrap(store);
// Should return object with 9 primitive + 22 output aliases = 31
// Should return object with 9 primitive + 24 output aliases = 33
expect(builtinSchemas).toHaveProperty("@ocas/schema");
expect(builtinSchemas).toHaveProperty("@ocas/string");
expect(builtinSchemas).toHaveProperty("@ocas/number");
@@ -53,7 +55,7 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(31);
expect(Object.keys(builtinSchemas)).toHaveLength(33);
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
+36
View File
@@ -367,6 +367,42 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
title: "ocas gc result",
},
],
[
"@ocas/output/export",
{
type: "object",
properties: {
nodes: { type: "number" },
vars: { type: "number" },
tags: { type: "number" },
},
title: "ocas export result",
},
],
[
"@ocas/output/import",
{
type: "object",
properties: {
nodes: {
type: "object",
properties: {
imported: { type: "number" },
skipped: { type: "number" },
},
},
vars: {
type: "object",
properties: {
created: { type: "number" },
updated: { type: "number" },
},
},
tags: { type: "number" },
},
title: "ocas import result",
},
],
];
/**
+423
View File
@@ -0,0 +1,423 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { exportBundle, importBundle, loadBundleStore } from "./bundle.js";
import { cborEncode } from "./cbor.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "ocas-bundle-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("exportBundle / importBundle / loadBundleStore", () => {
test("2.1 export: tar file structure includes cas/, vars.jsonl, tags.jsonl", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, {
type: "object",
properties: { x: { type: "number" } },
});
const aHash = store.cas.put(schemaHash, { x: 42 });
store.var.set("@test/config", aHash);
store.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
const stats = await exportBundle(store, ["@test/config"], out);
const buf = readFileSync(out);
// Standard tar should have 512-byte aligned blocks.
expect(buf.length % 512).toBe(0);
// Parse out the entry names from the tar.
const names = listTarEntries(buf);
expect(names.some((n) => n === `cas/${aHash}.bin`)).toBe(true);
expect(names.some((n) => n === `cas/${schemaHash}.bin`)).toBe(true);
expect(names).toContain("vars.jsonl");
expect(names).toContain("tags.jsonl");
expect(stats.nodes).toBeGreaterThanOrEqual(2);
expect(stats.vars).toBeGreaterThanOrEqual(1);
expect(stats.tags).toBeGreaterThanOrEqual(1);
});
test("2.2 export: CAS node binary identity is preserved", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "hello");
store.var.set("@test/h", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(store, ["@test/h"], out);
const buf = readFileSync(out);
const entries = readTarEntries(buf);
const casEntry = entries.find((e) => e.name === `cas/${aHash}.bin`);
expect(casEntry).toBeDefined();
const node = store.cas.get(aHash);
expect(node).not.toBeNull();
if (!node) return;
const expected = cborEncode({
type: node.type,
payload: node.payload,
timestamp: node.timestamp,
});
expect(casEntry?.content).toEqual(expected);
});
test("2.3 export: vars.jsonl contains parseable JSON lines", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "a");
const bHash = store.cas.put(schemaHash, "b");
store.var.set("@test/a", aHash);
store.var.set("@test/b", bHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(store, ["@test/a", "@test/b"], out);
const entries = readTarEntries(readFileSync(out));
const vars = entries.find((e) => e.name === "vars.jsonl");
expect(vars).toBeDefined();
const text = new TextDecoder().decode(vars?.content);
const lines = text.split("\n").filter((l) => l.length > 0);
const records = lines.map(
(l) => JSON.parse(l) as { name: string; value: string },
);
const names = records.map((r) => r.name);
expect(names).toContain("@test/a");
expect(names).toContain("@test/b");
const aRec = records.find((r) => r.name === "@test/a");
expect(aRec?.value).toBe(aHash);
});
test("2.4 export: tags.jsonl contains target/key/value records", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "tagged");
store.var.set("@test/t", aHash);
store.tag.tag(aHash, [
{ op: "set", key: "env", value: "prod" },
{ op: "set", key: "stable" },
]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(store, ["@test/t"], out);
const entries = readTarEntries(readFileSync(out));
const tagEntry = entries.find((e) => e.name === "tags.jsonl");
expect(tagEntry).toBeDefined();
const text = new TextDecoder().decode(tagEntry?.content);
const lines = text.split("\n").filter((l) => l.length > 0);
const records = lines.map(
(l) =>
JSON.parse(l) as {
target: string;
key: string;
value: string | null;
},
);
const env = records.find((r) => r.key === "env");
expect(env?.value).toBe("prod");
expect(env?.target).toBe(aHash);
const stable = records.find((r) => r.key === "stable");
expect(stable?.value).toBeNull();
});
test("2.5 export: accepts variable names and raw hashes as roots", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "x");
store.var.set("@test/c", aHash);
const out1 = join(tmpDir, "by-name.tar");
const out2 = join(tmpDir, "by-hash.tar");
await exportBundle(store, ["@test/c"], out1);
await exportBundle(store, [aHash], out2);
const names1 = listTarEntries(readFileSync(out1));
const names2 = listTarEntries(readFileSync(out2));
expect(names1).toContain(`cas/${aHash}.bin`);
expect(names2).toContain(`cas/${aHash}.bin`);
});
test("2.6 export: non-existent root throws", async () => {
const store = createMemoryStore();
bootstrap(store);
const out = join(tmpDir, "bundle.tar");
await expect(
exportBundle(store, ["@test/nonexistent"], out),
).rejects.toThrow();
});
test("2.7 import: nodes are written to target store", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, {
type: "object",
properties: { x: { type: "number" } },
});
const aHash = src.cas.put(schemaHash, { x: 1 });
src.var.set("@test/c", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst);
expect(dst.cas.has(aHash)).toBe(true);
const node = dst.cas.get(aHash);
expect(node?.type).toBe(schemaHash);
expect(node?.payload).toEqual({ x: 1 });
});
test("2.8 import: skip existing nodes (content-addressed dedup)", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "a");
src.var.set("@test/c", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
// Pre-populate destination with the same node
dst.cas.put(schemaHash, "a"); // wait — schemaHash may not exist in dst
// To deduplicate, we need to ensure the same hash is computed.
// Re-import the schema first via import.
const stats = await importBundle(out, dst);
// After two imports the second's nodes.skipped should equal nodes.imported of the first.
const stats2 = await importBundle(out, dst);
expect(stats2.nodes.skipped).toBeGreaterThan(0);
expect(stats2.nodes.imported).toBe(0);
void stats;
});
test("2.9 import: variables created without scope use original names", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst);
const v = dst.var.get("@test/config");
expect(v?.value).toBe(aHash);
});
test("2.10 import: scope remapping rewrites variable names", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst, { scope: "@imported" });
const remapped = dst.var.get("@imported/config");
expect(remapped?.value).toBe(aHash);
const original = dst.var.get("@test/config");
expect(original).toBeNull();
});
test("2.11 import: @ocas/* builtin variables are NOT remapped", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst, { scope: "@imported" });
// @ocas/schema, @ocas/string etc. should still be reachable as-is.
expect(dst.var.get("@ocas/schema")).not.toBeNull();
// No variant under the remapped scope.
expect(dst.var.get("@imported/schema")).toBeNull();
});
test("2.12 import: variable conflict — overwrite with stats marking 'updated'", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "imported");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
// Pre-populate destination with same name → different value.
const dstSchema = putSchema(dst, { type: "string" });
const bHash = dst.cas.put(dstSchema, "preexisting");
dst.var.set("@test/config", bHash);
const stats = await importBundle(out, dst);
expect(stats.vars.updated).toBeGreaterThanOrEqual(1);
// Value should now point at the imported hash.
const v = dst.var.get("@test/config", schemaHash);
expect(v?.value).toBe(aHash);
});
test("2.13 import: tags are applied to imported nodes", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "tagged");
src.var.set("@test/c", aHash);
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst);
const tags = dst.tag.tags(aHash);
expect(tags.some((t) => t.key === "env" && t.value === "prod")).toBe(true);
});
test("2.14 import: stats report nodes/vars/tags counts", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/c", aHash);
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
const stats = await importBundle(out, dst);
expect(stats.nodes.imported).toBeGreaterThan(0);
expect(stats.nodes.skipped).toBeGreaterThanOrEqual(0);
expect(stats.vars.created + stats.vars.updated).toBeGreaterThan(0);
expect(stats.tags).toBeGreaterThanOrEqual(1);
});
test("2.15 loadBundleStore: read-only Store from tar", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const bundleStore = await loadBundleStore(out);
expect(bundleStore.cas.get(aHash)).not.toBeNull();
expect(bundleStore.cas.has(aHash)).toBe(true);
const v = bundleStore.var.get("@test/config");
expect(v?.value).toBe(aHash);
const tags = bundleStore.tag.tags(aHash);
expect(tags.some((t) => t.key === "env" && t.value === "prod")).toBe(true);
});
test("2.16 loadBundleStore: walk works against bundle store", async () => {
const src = createMemoryStore();
bootstrap(src);
const refSchema = putSchema(src, {
type: "object",
properties: { next: { type: "string", format: "ocas_ref" } },
});
const stringSchema = putSchema(src, { type: "string" });
const bHash = src.cas.put(stringSchema, "b-content");
const aHash = src.cas.put(refSchema, { next: bHash });
src.var.set("@test/root", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/root"], out);
const bundleStore = await loadBundleStore(out);
const { walk } = await import("./schema.js");
const visited: string[] = [];
walk(bundleStore, aHash, (h) => visited.push(h));
expect(visited).toContain(aHash);
expect(visited).toContain(bHash);
});
});
// ---- Tar parser (minimal POSIX/ustar reader) used by tests ----
type TarEntry = { name: string; content: Uint8Array };
function readTarEntries(buf: Buffer): TarEntry[] {
const entries: TarEntry[] = [];
let offset = 0;
while (offset + 512 <= buf.length) {
const header = buf.subarray(offset, offset + 512);
// End-of-archive: two consecutive zero blocks.
if (header.every((b) => b === 0)) break;
const name = readCString(header, 0, 100);
const sizeStr = readCString(header, 124, 12).trim();
const size = sizeStr === "" ? 0 : parseInt(sizeStr, 8);
offset += 512;
const content = buf.subarray(offset, offset + size);
entries.push({ name, content: new Uint8Array(content) });
// Pad to 512-byte boundary.
offset += Math.ceil(size / 512) * 512;
}
return entries;
}
function listTarEntries(buf: Buffer): string[] {
return readTarEntries(buf).map((e) => e.name);
}
function readCString(buf: Buffer, start: number, len: number): string {
const slice = buf.subarray(start, start + len);
let end = slice.length;
for (let i = 0; i < slice.length; i++) {
if (slice[i] === 0) {
end = i;
break;
}
}
return slice.subarray(0, end).toString("utf8");
}
// Suppress unused import warnings.
void writeFileSync;
+394
View File
@@ -0,0 +1,394 @@
import { readFileSync, writeFileSync } from "node:fs";
import { decode } from "cborg";
import { bootstrap } from "./bootstrap.js";
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
import { cborEncode } from "./cbor.js";
import { computeClosure } from "./closure.js";
import { createMemoryStore } from "./store.js";
import type { CasNode, Hash, Store, Tag } from "./types.js";
import type { Variable } from "./variable.js";
/**
* Stats returned by `exportBundle`.
*/
export type ExportStats = {
nodes: number;
vars: number;
tags: number;
};
/**
* Options for `importBundle`.
*/
export type ImportOptions = {
/** Replace the original `@scope` of each non-builtin variable with this value. */
scope?: string;
};
/**
* Stats returned by `importBundle`.
*/
export type ImportStats = {
nodes: { imported: number; skipped: number };
vars: { created: number; updated: number };
tags: number;
};
/** Import via CBOR using cborg, mirroring how FsStore decodes nodes. */
const BUILTIN_PREFIX = "@ocas/";
/**
* Resolve a single root spec (variable name OR raw hash) into a hash. Throws
* if the name does not resolve and the input is not a hash.
*/
function resolveRoot(store: Store, input: string): Hash {
if (/^[0-9A-HJKMNP-TV-Z]{13}$/.test(input)) {
if (!store.cas.has(input)) {
throw new Error(`Root hash not found in store: ${input}`);
}
return input as Hash;
}
const variants = store.var.list({ exactName: input });
const first = variants[0];
if (!first) {
throw new Error(`Root variable not found: ${input}`);
}
return first.value as Hash;
}
/**
* Compute the transitive CAS closure of `roots`, write a tar archive at
* `outputPath` containing all CAS nodes (`cas/<hash>.bin`), variables
* (`vars.jsonl`), and tags (`tags.jsonl`).
*/
export async function exportBundle(
store: Store,
roots: string[],
outputPath: string,
): Promise<ExportStats> {
// Resolve every root before computing the closure so missing names error
// early.
const rootHashes = roots.map((r) => resolveRoot(store, r));
const closure = computeClosure(store, rootHashes);
const entries: TarEntry[] = [];
// CAS nodes — one CBOR-encoded file per node, named by hash.
// Order is deterministic by sorted hash.
const sortedNodes = [...closure.nodes].sort();
for (const hash of sortedNodes) {
const node = store.cas.get(hash);
if (!node) continue;
const content = cborEncode({
type: node.type,
payload: node.payload,
timestamp: node.timestamp,
});
entries.push({ name: `cas/${hash}.bin`, content });
}
// Variables — JSON-lines.
const sortedVars = [...closure.vars].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
const varLines = sortedVars
.map((v) =>
JSON.stringify({
name: v.name,
schema: v.schema,
value: v.value,
created: v.created,
updated: v.updated,
tags: v.tags,
labels: v.labels,
}),
)
.join("\n");
entries.push({
name: "vars.jsonl",
content: new TextEncoder().encode(
varLines + (varLines.length > 0 ? "\n" : ""),
),
});
// Tags — JSON-lines, one per tag.
const tagLines: string[] = [];
const sortedTagTargets = [...closure.tags.keys()].sort();
let tagCount = 0;
for (const target of sortedTagTargets) {
const tagList = closure.tags.get(target) ?? [];
for (const t of tagList) {
tagLines.push(
JSON.stringify({
target: t.target,
key: t.key,
value: t.value,
created: t.created,
}),
);
tagCount++;
}
}
const tagText = tagLines.join("\n");
entries.push({
name: "tags.jsonl",
content: new TextEncoder().encode(
tagText + (tagText.length > 0 ? "\n" : ""),
),
});
// Pack into tar and write to disk.
const tar = packTar(entries);
writeFileSync(outputPath, tar);
return {
nodes: sortedNodes.length,
vars: sortedVars.length,
tags: tagCount,
};
}
/**
* Read a bundle tar archive from disk, returning the parsed components
* without applying them to a store.
*/
function readBundle(bundlePath: string): {
nodes: Map<Hash, CasNode>;
vars: Variable[];
tags: Tag[];
} {
const buf = readFileSync(bundlePath);
const entries = unpackTar(buf);
const nodes = new Map<Hash, CasNode>();
let vars: Variable[] = [];
let tags: Tag[] = [];
for (const entry of entries) {
if (entry.name.startsWith("cas/") && entry.name.endsWith(".bin")) {
const hash = entry.name.slice(4, -4) as Hash;
const node = decode(entry.content) as CasNode;
nodes.set(hash, node);
} else if (entry.name === "vars.jsonl") {
const text = new TextDecoder().decode(entry.content);
vars = text
.split("\n")
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as Variable);
} else if (entry.name === "tags.jsonl") {
const text = new TextDecoder().decode(entry.content);
tags = text
.split("\n")
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as Tag);
}
}
return { nodes, vars, tags };
}
/**
* Apply scope remapping to a variable name. `@ocas/*` is reserved and never
* remapped. Other names get `^@[^/]+` replaced with the new scope.
*/
function remapVarName(name: string, scope: string | undefined): string {
if (scope === undefined) return name;
if (name.startsWith(BUILTIN_PREFIX)) return name;
// Replace leading @scope with the new scope. The format is `@scope/rest`.
return name.replace(/^@[^/]+/, scope);
}
/**
* Read a bundle from disk and apply its contents to `target`.
*/
export async function importBundle(
bundlePath: string,
target: Store,
options?: ImportOptions,
): Promise<ImportStats> {
// Ensure target is bootstrapped so meta-schema is available (importing the
// meta-schema as a regular CAS node would still work since hash-equal
// self-referencing nodes dedup).
bootstrap(target);
const { nodes, vars, tags } = readBundle(bundlePath);
// Sort nodes so that meta-schema (self-referencing) is imported first,
// then types (whose `type` is the meta-schema), then leaves. The simple
// heuristic: import nodes whose `type` is already present (or self) until
// the queue stabilises.
let imported = 0;
let skipped = 0;
const remaining = new Map(nodes);
let progress = true;
while (remaining.size > 0 && progress) {
progress = false;
for (const [hash, node] of [...remaining]) {
const ready = node.type === hash || target.cas.has(node.type);
if (!ready) continue;
if (target.cas.has(hash)) {
skipped++;
} else if (node.type === hash) {
// Self-referencing meta — import via bootstrap-capable interface.
// Fall back to put if the store doesn't expose BOOTSTRAP_STORE.
const cas = target.cas as unknown as {
[k: symbol]: ((p: unknown) => Hash) | undefined;
};
const fn = cas[BOOTSTRAP_STORE];
if (fn) {
fn(node.payload);
} else {
target.cas.put(node.type, node.payload);
}
imported++;
} else {
target.cas.put(node.type, node.payload);
imported++;
}
remaining.delete(hash);
progress = true;
}
}
// If anything remains, type chains were unresolvable — import them anyway.
for (const [hash, node] of remaining) {
if (target.cas.has(hash)) {
skipped++;
} else {
target.cas.put(node.type, node.payload);
imported++;
}
}
// Variables.
let created = 0;
let updated = 0;
for (const v of vars) {
const newName = remapVarName(v.name, options?.scope);
// @ocas/* names already exist after bootstrap; if name+schema match value
// they will be silently no-op'd by the store.
const existing = target.var.get(newName, v.schema);
target.var.set(newName, v.value, {
tags: v.tags ?? {},
labels: v.labels ?? [],
});
if (existing === null) {
created++;
} else {
updated++;
}
}
// Tags. Apply each tag to its target.
for (const t of tags) {
target.tag.tag(t.target, [
t.value === null
? { op: "set", key: t.key }
: { op: "set", key: t.key, value: t.value },
]);
}
return {
nodes: { imported, skipped },
vars: { created, updated },
tags: tags.length,
};
}
/**
* Build a read-only `Store` whose contents come from a bundle tar file.
*/
export async function loadBundleStore(bundlePath: string): Promise<Store> {
const store = createMemoryStore();
// Apply the bundle's contents but suppress the bootstrap-only nodes so
// the bundle file remains the source of truth.
await importBundle(bundlePath, store);
return store;
}
// ---------------------------------------------------------------------------
// Minimal tar pack/unpack — POSIX ustar format, regular files only.
// ---------------------------------------------------------------------------
type TarEntry = { name: string; content: Uint8Array };
function packTar(entries: TarEntry[]): Buffer {
const blocks: Buffer[] = [];
for (const entry of entries) {
const header = Buffer.alloc(512);
writeString(header, entry.name, 0, 100);
writeOctal(header, 0o644, 100, 8);
writeOctal(header, 0, 108, 8);
writeOctal(header, 0, 116, 8);
writeOctal(header, entry.content.length, 124, 12);
writeOctal(header, Math.floor(Date.now() / 1000), 136, 12);
// checksum placeholder — 8 spaces, then computed.
for (let i = 0; i < 8; i++) header[148 + i] = 0x20;
header[156] = 0x30; // typeflag '0' (regular file)
writeString(header, "ustar ", 257, 8); // GNU-style ustar magic+version
let cksum = 0;
for (let i = 0; i < 512; i++) cksum += header[i] as number;
writeOctal(header, cksum, 148, 7);
header[155] = 0;
blocks.push(header);
const content = Buffer.from(entry.content);
blocks.push(content);
// Pad to 512.
const pad = (512 - (content.length % 512)) % 512;
if (pad > 0) blocks.push(Buffer.alloc(pad));
}
// End-of-archive: two zero blocks.
blocks.push(Buffer.alloc(512));
blocks.push(Buffer.alloc(512));
return Buffer.concat(blocks);
}
function unpackTar(buf: Buffer): TarEntry[] {
const entries: TarEntry[] = [];
let offset = 0;
while (offset + 512 <= buf.length) {
const header = buf.subarray(offset, offset + 512);
if (header.every((b) => b === 0)) break;
const name = readCString(header, 0, 100);
const sizeStr = readCString(header, 124, 12).trim();
const size = sizeStr === "" ? 0 : parseInt(sizeStr, 8);
offset += 512;
const content = new Uint8Array(buf.subarray(offset, offset + size));
entries.push({ name, content });
offset += Math.ceil(size / 512) * 512;
}
return entries;
}
function writeString(
buf: Buffer,
str: string,
offset: number,
len: number,
): void {
const data = Buffer.from(str, "utf8");
const n = Math.min(data.length, len);
data.copy(buf, offset, 0, n);
for (let i = n; i < len; i++) buf[offset + i] = 0;
}
function writeOctal(
buf: Buffer,
value: number,
offset: number,
len: number,
): void {
const str = value.toString(8).padStart(len - 1, "0");
writeString(buf, str, offset, len - 1);
buf[offset + len - 1] = 0;
}
function readCString(buf: Buffer, start: number, len: number): string {
const slice = buf.subarray(start, start + len);
let end = slice.length;
for (let i = 0; i < slice.length; i++) {
if (slice[i] === 0) {
end = i;
break;
}
}
return slice.subarray(0, end).toString("utf8");
}
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { computeClosure } from "./closure.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
describe("computeClosure", () => {
test("1.1 basic node traversal — collects A, B, C linked by ocas_ref", () => {
const store = createMemoryStore();
bootstrap(store);
const refSchema = putSchema(store, {
type: "object",
properties: {
next: { type: "string", format: "ocas_ref" },
name: { type: "string" },
},
});
const stringSchema = putSchema(store, { type: "string" });
const cHash = store.cas.put(stringSchema, "leaf-c");
const bHash = store.cas.put(refSchema, { next: cHash, name: "b" });
const aHash = store.cas.put(refSchema, { next: bHash, name: "a" });
const result = computeClosure(store, [aHash]);
expect(result.nodes.has(aHash)).toBe(true);
expect(result.nodes.has(bHash)).toBe(true);
expect(result.nodes.has(cHash)).toBe(true);
});
test("1.2 schema chain inclusion — schema and meta-schema are part of the closure", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const metaHash = aliases["@ocas/schema"] as string;
const schemaHash = putSchema(store, { type: "object" });
const nodeHash = store.cas.put(schemaHash, { foo: "bar" });
const result = computeClosure(store, [nodeHash]);
expect(result.nodes.has(nodeHash)).toBe(true);
expect(result.nodes.has(schemaHash)).toBe(true);
expect(result.nodes.has(metaHash)).toBe(true);
});
test("1.3 template variable nodes — template content is included", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"] as string;
const schemaHash = putSchema(store, { type: "object" });
const nodeHash = store.cas.put(schemaHash, { x: 1 });
// Register a template for schemaHash
const templateContent = "rendered: {{ x }}";
const contentHash = store.cas.put(stringHash, templateContent);
store.var.set(`@ocas/template/text/${schemaHash}`, contentHash);
const result = computeClosure(store, [nodeHash]);
expect(result.nodes.has(nodeHash)).toBe(true);
expect(result.nodes.has(schemaHash)).toBe(true);
expect(result.nodes.has(contentHash)).toBe(true);
const templateVarNames = result.vars.map((v) => v.name);
expect(templateVarNames).toContain(`@ocas/template/text/${schemaHash}`);
});
test("1.4 multiple roots — union of closures", () => {
const store = createMemoryStore();
bootstrap(store);
const stringSchema = putSchema(store, { type: "string" });
const aHash = store.cas.put(stringSchema, "alpha");
const bHash = store.cas.put(stringSchema, "beta");
store.var.set("@test/a", aHash);
store.var.set("@test/b", bHash);
const result = computeClosure(store, [aHash, bHash]);
expect(result.nodes.has(aHash)).toBe(true);
expect(result.nodes.has(bHash)).toBe(true);
});
test("1.5 cycle handling — terminates on self-references", () => {
const store = createMemoryStore();
bootstrap(store);
const refSchema = putSchema(store, {
type: "object",
properties: {
next: { type: "string", format: "ocas_ref" },
},
});
// Build a self-loop by hashing first then storing
const stringSchema = putSchema(store, { type: "string" });
const placeholder = store.cas.put(stringSchema, "self");
// Create a cycle A -> B -> A
const bHash = store.cas.put(refSchema, { next: placeholder });
const aHash = store.cas.put(refSchema, { next: bHash });
// Mutate B to point back to A is impossible in CAS — instead test that
// the same node is visited only once even if reached via multiple paths.
const result = computeClosure(store, [aHash, aHash]);
expect(result.nodes.has(aHash)).toBe(true);
expect(result.nodes.has(bHash)).toBe(true);
// The placeholder is reached from B
expect(result.nodes.has(placeholder)).toBe(true);
// Each node appears exactly once in the set
expect(result.nodes.size).toBeGreaterThan(0);
});
test("1.6 variables pointing into closure are collected", () => {
const store = createMemoryStore();
bootstrap(store);
const stringSchema = putSchema(store, { type: "string" });
const xHash = store.cas.put(stringSchema, "x-content");
const yHash = store.cas.put(stringSchema, "y-content");
store.var.set("@test/x", xHash);
store.var.set("@test/y", yHash);
const result = computeClosure(store, [xHash]);
const names = result.vars.map((v) => v.name);
expect(names).toContain("@test/x");
expect(names).not.toContain("@test/y");
});
test("1.7 @ocas/* builtin vars whose values are in closure are collected", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const metaHash = aliases["@ocas/schema"] as string;
const result = computeClosure(store, [metaHash]);
// @ocas/schema is a builtin var pointing to metaHash
const names = result.vars.map((v) => v.name);
expect(names).toContain("@ocas/schema");
});
test("1.8 tags on closure nodes are collected", () => {
const store = createMemoryStore();
bootstrap(store);
const stringSchema = putSchema(store, { type: "string" });
const aHash = store.cas.put(stringSchema, "tagged-a");
const bHash = store.cas.put(stringSchema, "tagged-b");
store.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
store.tag.tag(bHash, [{ op: "set", key: "env", value: "dev" }]);
const result = computeClosure(store, [aHash]);
const aTags = result.tags.get(aHash);
expect(aTags).toBeDefined();
expect(aTags?.some((t) => t.key === "env" && t.value === "prod")).toBe(
true,
);
// B is not in the closure
expect(result.tags.has(bHash)).toBe(false);
});
test("1.9 empty roots → empty closure", () => {
const store = createMemoryStore();
bootstrap(store);
const result = computeClosure(store, []);
expect(result.nodes.size).toBe(0);
expect(result.vars).toEqual([]);
expect(result.tags.size).toBe(0);
});
test("1.10 template content for any schema in closure is included", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"] as string;
// schema A has template, schema B does not — both reachable via refs
const refSchema = putSchema(store, {
type: "object",
properties: {
next: { type: "string", format: "ocas_ref" },
},
});
const innerSchema = putSchema(store, { type: "object" });
const innerNode = store.cas.put(innerSchema, { x: 1 });
const outerNode = store.cas.put(refSchema, { next: innerNode });
const tplA = store.cas.put(stringHash, "A:{{ next }}");
const tplInner = store.cas.put(stringHash, "INNER");
store.var.set(`@ocas/template/text/${refSchema}`, tplA);
store.var.set(`@ocas/template/text/${innerSchema}`, tplInner);
const result = computeClosure(store, [outerNode]);
expect(result.nodes.has(tplA)).toBe(true);
expect(result.nodes.has(tplInner)).toBe(true);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { walk } from "./schema.js";
import type { Hash, Store, Tag } from "./types.js";
import type { Variable } from "./variable.js";
/**
* Result of a closure computation: the set of CAS hashes reachable from a
* set of roots, along with the variables and tags that point into the
* closure.
*/
export type ClosureResult = {
/** All CAS node hashes reachable from the roots. */
nodes: Set<Hash>;
/** Variables whose value is in the closure (excluding orphaned vars). */
vars: Variable[];
/** Tags grouped by their target hash (only targets in the closure). */
tags: Map<Hash, Tag[]>;
};
/**
* Compute the transitive closure starting from a set of root CAS hashes.
*
* The closure is a self-contained subset of a Store: every node it points
* at via `ocas_ref` fields, every schema it depends on (the meta-schema
* chain), and every template variable referencing a schema in the closure
* is included.
*
* Variables that point at hashes in the closure (after node and template
* walks) are returned. Tags whose target is in the closure are returned.
*
* Roots that do not exist in the store are silently skipped callers
* (e.g. `exportBundle`) should validate roots beforehand if strictness is
* required.
*/
export function computeClosure(store: Store, roots: Hash[]): ClosureResult {
const nodes = new Set<Hash>();
// Phase 1: walk refs from each root.
for (const root of roots) {
if (!store.cas.has(root)) continue;
walk(store, root, (hash, node) => {
nodes.add(hash);
nodes.add(node.type);
});
}
// Phase 2: walk the schema chain to include meta-schemas (e.g. @ocas/schema)
// and any other type ancestors.
const schemasToWalk = new Set<Hash>();
for (const hash of nodes) {
const node = store.cas.get(hash);
if (node) schemasToWalk.add(node.type);
}
for (const schemaHash of schemasToWalk) {
let current: Hash | null = schemaHash;
while (current !== null && !nodes.has(current)) {
nodes.add(current);
const node = store.cas.get(current);
if (!node || node.type === current) break;
current = node.type;
}
}
// Phase 3: collect template variables for each schema in the closure.
// Templates are stored as `@ocas/template/text/<schema-hash>` variables.
// If a template exists for a schema in the closure, walk its content too.
const templateVars: Variable[] = [];
// Snapshot existing schema list — we may add nodes during template walks
const initialNodes = [...nodes];
for (const hash of initialNodes) {
const templateName = `@ocas/template/text/${hash}`;
const variants = store.var.list({ exactName: templateName });
for (const variant of variants) {
templateVars.push(variant);
// Walk the template content node
walk(store, variant.value, (h, n) => {
nodes.add(h);
nodes.add(n.type);
});
// And its schema chain
const tNode = store.cas.get(variant.value);
if (tNode) {
let current: Hash | null = tNode.type;
while (current !== null && !nodes.has(current)) {
nodes.add(current);
const node = store.cas.get(current);
if (!node || node.type === current) break;
current = node.type;
}
}
}
}
// Phase 4: collect variables whose value is in the closure. Template
// variables are already collected; deduplicate.
const varKey = (v: Variable): string => `${v.name}\u0000${v.schema}`;
const seenVars = new Set<string>(templateVars.map(varKey));
const vars: Variable[] = [...templateVars];
const allVars = store.var.list();
for (const v of allVars) {
if (!nodes.has(v.value)) continue;
const key = varKey(v);
if (seenVars.has(key)) continue;
seenVars.add(key);
vars.push(v);
}
// Phase 5: collect tags for each node in the closure.
const tags = new Map<Hash, Tag[]>();
for (const hash of nodes) {
const tagList = store.tag.tags(hash);
if (tagList.length > 0) {
tags.set(hash, tagList);
}
}
return { nodes, vars, tags };
}
+217 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { gc } from "./gc.js";
import { putSchema } from "./schema.js";
@@ -119,3 +119,219 @@ describe("GC - Variable Model Refactoring", () => {
expect(store.cas.has(hashB)).toBe(true);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Suite B: gc end-to-end with oneOf step chains (issue #93)
// ──────────────────────────────────────────────────────────────────────────────
describe("GC - oneOf step chain preservation (#93)", () => {
test("B.1 preserves a 3-step chain joined by oneOf nullable prev", async () => {
const store = createMemoryStore();
bootstrap(store);
const stepSchema = putSchema(store, {
type: "object",
properties: {
payload: { type: "string" },
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const step1 = store.cas.put(stepSchema, { payload: "a", prev: null });
const step2 = store.cas.put(stepSchema, { payload: "b", prev: step1 });
const step3 = store.cas.put(stepSchema, { payload: "c", prev: step2 });
const orphanStep = store.cas.put(stepSchema, {
payload: "orphan",
prev: null,
});
store.var.set("@test/thread/head", step3);
gc(store);
expect(store.cas.has(step1)).toBe(true);
expect(store.cas.has(step2)).toBe(true);
expect(store.cas.has(step3)).toBe(true);
expect(store.cas.has(stepSchema)).toBe(true);
expect(store.cas.has(orphanStep)).toBe(false);
});
test("B.2 preserves a chain that mixes oneOf detail refs", async () => {
const store = createMemoryStore();
bootstrap(store);
const detailSchema = putSchema(store, {
type: "object",
properties: { info: { type: "string" } },
});
const stepSchema = putSchema(store, {
type: "object",
properties: {
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
detail: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const detail1 = store.cas.put(detailSchema, { info: "d1" });
const detail2 = store.cas.put(detailSchema, { info: "d2" });
const step1 = store.cas.put(stepSchema, {
prev: null,
detail: detail1,
});
const step2 = store.cas.put(stepSchema, {
prev: step1,
detail: detail2,
});
store.var.set("@test/thread/head", step2);
gc(store);
expect(store.cas.has(step1)).toBe(true);
expect(store.cas.has(step2)).toBe(true);
expect(store.cas.has(detail1)).toBe(true);
expect(store.cas.has(detail2)).toBe(true);
});
test("B.3 preserves a workflow node referenced via oneOf from a step", async () => {
const store = createMemoryStore();
bootstrap(store);
const workflowSchema = putSchema(store, {
type: "object",
properties: { name: { type: "string" } },
});
const stepSchema = putSchema(store, {
type: "object",
properties: {
workflow: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const workflowNode = store.cas.put(workflowSchema, {
name: "solve-issue",
});
const step = store.cas.put(stepSchema, { workflow: workflowNode });
store.var.set("@test/thread/head", step);
store.var.set("@uwf/registry/solve-issue", workflowNode);
gc(store);
expect(store.cas.has(step)).toBe(true);
expect(store.cas.has(workflowNode)).toBe(true);
});
test("B.4 regression: existing anyOf traversal still works", async () => {
const store = createMemoryStore();
bootstrap(store);
const childSchema = putSchema(store, { type: "string" });
const parentSchema = putSchema(store, {
type: "object",
properties: {
child: {
anyOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const child = store.cas.put(childSchema, "child-value");
const parent = store.cas.put(parentSchema, { child });
store.var.set("@test/parent", parent);
gc(store);
expect(store.cas.has(parent)).toBe(true);
expect(store.cas.has(child)).toBe(true);
});
test("B.5 reports correct stats with oneOf chains", async () => {
const store = createMemoryStore();
bootstrap(store);
const stepSchema = putSchema(store, {
type: "object",
properties: {
payload: { type: "string" },
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const step1 = store.cas.put(stepSchema, { payload: "a", prev: null });
const step2 = store.cas.put(stepSchema, { payload: "b", prev: step1 });
const step3 = store.cas.put(stepSchema, { payload: "c", prev: step2 });
store.cas.put(stepSchema, { payload: "orphan", prev: null });
store.var.set("@test/thread/head", step3);
const stats = gc(store);
expect(stats.collected).toBe(1);
expect(stats.reachable).toBeGreaterThanOrEqual(4);
expect(stats.scanned).toBeGreaterThanOrEqual(1);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Suite C: gc preserves template content for reachable schemas (issue #93)
// ──────────────────────────────────────────────────────────────────────────────
describe("GC - template content preservation (#93)", () => {
test("C.1 preserves @ocas/template/text/<schema> content when schema is reachable", async () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"] as string;
const schemaA = putSchema(store, {
type: "object",
properties: { x: { type: "number" } },
});
const nodeA = store.cas.put(schemaA, { x: 42 });
store.var.set("@test/a", nodeA);
const tplA = store.cas.put(stringHash, "rendered: {{x}}");
store.var.set(`@ocas/template/text/${schemaA}`, tplA);
gc(store);
expect(store.cas.has(tplA)).toBe(true);
const tplVar = store.var.get(`@ocas/template/text/${schemaA}`);
expect(tplVar).not.toBeNull();
expect(tplVar?.value).toBe(tplA);
});
test("C.2 removes orphan template content for an unreachable schema", async () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"] as string;
const schemaA = putSchema(store, {
type: "object",
properties: { x: { type: "number" } },
});
// Note: do NOT bind any variable to a node typed by schemaA — schemaA is
// unreachable as a typeHash of any reachable node.
const otherSchema = putSchema(store, { type: "string" });
const otherNode = store.cas.put(otherSchema, "other");
store.var.set("@test/other", otherNode);
const tplA = store.cas.put(stringHash, "rendered: {{x}}");
store.var.set(`@ocas/template/text/${schemaA}`, tplA);
gc(store);
expect(store.cas.has(tplA)).toBe(false);
});
});
+40 -2
View File
@@ -8,10 +8,17 @@ export interface GcStats {
scanned: number; // Variables scanned as roots
}
const TEMPLATE_VAR_PREFIX = "@ocas/template/text/";
/**
* Garbage collection: mark-and-sweep algorithm
* - Roots: all variable values (global, not scoped)
* - Roots: all variable values (global, not scoped), excluding
* `@ocas/template/text/*` variables those are added in a follow-up
* phase only when their referenced schema is itself reachable.
* - Mark: recursively walk refs from roots
* - Template phase: for every reachable schema, walk the contents of its
* `@ocas/template/text/<schema>` template variable (mirrors
* `computeClosure` Phase 3).
* - Sweep: delete unmarked nodes
* - Schema preservation: schemas of reachable nodes are also marked
*/
@@ -21,9 +28,13 @@ export function gc(store: Store): GcStats {
const variables = store.var.list();
const scanned = variables.length;
// Collect unique root hashes from all variables
// Collect unique root hashes from all variables, except template
// variables (`@ocas/template/text/*`). Template variables are processed
// in a follow-up phase so their content is preserved only when the
// referenced schema is itself reachable from non-template roots.
const roots = new Set<Hash>();
for (const variable of variables) {
if (variable.name.startsWith(TEMPLATE_VAR_PREFIX)) continue;
roots.add(variable.value);
}
@@ -63,6 +74,33 @@ export function gc(store: Store): GcStats {
}
}
// Template phase: include `@ocas/template/text/<schema>` content nodes
// when their schema is in the reachable set (mirrors closure.ts Phase 3).
// Snapshot the current reachable set before walking template content so
// that template-only nodes do not transitively pull in further templates.
const reachableSnapshot = [...reachable];
for (const hash of reachableSnapshot) {
const templateName = `${TEMPLATE_VAR_PREFIX}${hash}`;
const variants = store.var.list({ exactName: templateName });
for (const variant of variants) {
walk(store, variant.value, (h, n) => {
reachable.add(h);
reachable.add(n.type);
});
// Walk the template content's schema chain too
const tNode = store.cas.get(variant.value);
if (tNode) {
let current: Hash | null = tNode.type;
while (current !== null && !reachable.has(current)) {
reachable.add(current);
const node = store.cas.get(current);
if (!node || node.type === current) break;
current = node.type;
}
}
}
}
// Preserve all self-referencing nodes (bootstrap meta-schema)
// These are nodes where type === hash
const allHashes = store.cas.listAll();
+5 -5
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { cborEncode } from "./cbor.js";
@@ -269,7 +269,7 @@ describe("bootstrap", () => {
);
});
test("returns a map with 30 built-in schema aliases", async () => {
test("returns a map with built-in schema aliases", async () => {
const store = createMemoryStore();
const builtinSchemas = bootstrap(store);
@@ -289,7 +289,7 @@ describe("bootstrap", () => {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(Object.keys(builtinSchemas)).toHaveLength(31);
expect(Object.keys(builtinSchemas)).toHaveLength(33);
});
test("meta-schema node is stored and retrievable", async () => {
@@ -326,7 +326,7 @@ describe("bootstrap", () => {
const h2 = bootstrap(store);
expect(h1).toEqual(h2);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 22 outputs)
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 24 outputs)
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(32);
});
});
+9
View File
@@ -1,7 +1,16 @@
export { bootstrap } from "./bootstrap.js";
export type { BootstrapCapableStore } from "./bootstrap-capable.js";
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
export {
type ExportStats,
exportBundle,
type ImportOptions,
type ImportStats,
importBundle,
loadBundleStore,
} from "./bundle.js";
export { cborEncode } from "./cbor.js";
export { type ClosureResult, computeClosure } from "./closure.js";
export {
CasNodeNotFoundError,
InvalidTagFormatError,
+21 -21
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { renderWithTemplate } from "./liquid-render.js";
import { putSchema } from "./schema.js";
@@ -598,7 +598,7 @@ describe("Suite 4: Render Flow Integration", () => {
epsilon: 0.01,
});
expect(output.length).toBe(0);
await expect(output.length).toBe(0);
} finally {
await cleanup();
}
@@ -623,13 +623,13 @@ describe("Suite 4: Render Flow Integration", () => {
);
store.var.set(`@ocas/template/text/${nodeSchema}`, template);
await expect(async () => {
await renderWithTemplate(store, nodeHash, {
await expect(
renderWithTemplate(store, nodeHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
}).toThrow();
}),
).rejects.toThrow();
} finally {
await cleanup();
}
@@ -905,8 +905,8 @@ describe("Suite 6: Recursive Rendering Edge Cases", () => {
});
expect(output).toContain("Item: item1");
expect(output).toContain("Item: item2");
expect(output).toContain("Item: item3");
await expect(output).toContain("Item: item2");
await expect(output).toContain("Item: item3");
} finally {
await cleanup();
}
@@ -937,7 +937,7 @@ describe("Suite 7: Error Handling & Edge Cases", () => {
epsilon: 0.01,
});
expect(output).toBeDefined();
await expect(output).toBeDefined();
} finally {
await cleanup();
}
@@ -970,13 +970,13 @@ describe("Suite 7: Error Handling & Edge Cases", () => {
);
store.var.set(`@ocas/template/text/${parentSchema}`, template);
await expect(async () => {
await renderWithTemplate(store, parentHash, {
await expect(
renderWithTemplate(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
}).toThrow(/decay/);
}),
).rejects.toThrow(/decay/);
} finally {
await cleanup();
}
@@ -1009,13 +1009,13 @@ describe("Suite 7: Error Handling & Edge Cases", () => {
);
store.var.set(`@ocas/template/text/${parentSchema}`, template);
await expect(async () => {
await renderWithTemplate(store, parentHash, {
await expect(
renderWithTemplate(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
}).toThrow();
}),
).rejects.toThrow();
} finally {
await cleanup();
}
@@ -1048,13 +1048,13 @@ describe("Suite 7: Error Handling & Edge Cases", () => {
);
store.var.set(`@ocas/template/text/${parentSchema}`, template);
await expect(async () => {
await renderWithTemplate(store, parentHash, {
await expect(
renderWithTemplate(store, parentHash, {
resolution: 1.0,
decay: 0.5,
epsilon: 0.01,
});
}).toThrow(/decay/);
}),
).rejects.toThrow(/decay/);
} finally {
await cleanup();
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
import { createMemoryStore } from "./store.js";
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
function* walk(dir: string): Generator<string> {
for (const name of readdirSync(dir)) {
@@ -16,7 +16,7 @@ function* walk(dir: string): Generator<string> {
describe("no SQLite in @ocas/core", () => {
test("source files do not import sqlite", () => {
const srcDir = import.meta.dir;
const srcDir = import.meta.dirname;
const needle = ["bun", "sqlite"].join(":");
for (const file of walk(srcDir)) {
if (!file.endsWith(".ts")) continue;
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { registerOutputTemplates } from "./output-templates.js";
import { createMemoryStore } from "./store.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { CasNodeNotFoundError } from "./errors.js";
import { render, renderAsync, renderDirect } from "./render.js";
+80 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { getSchema, putSchema, refs, validate, walk } from "./schema.js";
@@ -743,3 +743,82 @@ describe("bootstrap meta-schema self-reference", () => {
expect(refList).toContain(targetHash);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Suite A: collectRefs() oneOf traversal (issue #93)
// ──────────────────────────────────────────────────────────────────────────────
describe("collectRefs oneOf traversal", () => {
test("A.1 returns the ref hash from the chosen oneOf branch (string variant)", async () => {
const store = createMemoryStore();
const innerSchema = putSchema(store, { type: "string" });
const schema = putSchema(store, {
type: "object",
properties: {
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const targetHash = store.cas.put(innerSchema, "target");
const nodeHash = store.cas.put(schema, { prev: targetHash });
const node = store.cas.get(nodeHash) as CasNode;
expect(refs(store, node)).toContain(targetHash);
});
test("A.2 returns no ref when oneOf matches the null variant", async () => {
const store = createMemoryStore();
const schema = putSchema(store, {
type: "object",
properties: {
prev: {
oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }],
},
},
});
const nodeHash = store.cas.put(schema, { prev: null });
const node = store.cas.get(nodeHash) as CasNode;
expect(refs(store, node)).toEqual([]);
});
test("A.3 traverses nested combinators inside oneOf", async () => {
const store = createMemoryStore();
const innerSchema = putSchema(store, { type: "string" });
const schema = putSchema(store, {
oneOf: [
{ type: "null" },
{
type: "object",
properties: { ref: { type: "string", format: "ocas_ref" } },
},
],
});
const targetHash = store.cas.put(innerSchema, "target");
const nodeHash = store.cas.put(schema, { ref: targetHash });
const node = store.cas.get(nodeHash) as CasNode;
expect(refs(store, node)).toContain(targetHash);
});
test("A.4 multiple ref branches in oneOf all surface", async () => {
const store = createMemoryStore();
const innerSchema = putSchema(store, { type: "string" });
const schema = putSchema(store, {
oneOf: [
{ type: "string", format: "ocas_ref" },
{ type: "string", format: "ocas_ref" },
],
});
const targetHash = store.cas.put(innerSchema, "target");
const nodeHash = store.cas.put(schema, targetHash);
const node = store.cas.get(nodeHash) as CasNode;
const result = refs(store, node);
expect(result).toContain(targetHash);
});
});
+11
View File
@@ -311,6 +311,17 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
return result;
}
// oneOf — JSON Schema requires exactly one branch to validate, but for
// ref collection we conservatively traverse every branch (the meta-schema
// accepts oneOf alongside anyOf, and we cannot statically know which
// branch the value will match). Mirrors anyOf handling.
if (Array.isArray(schema.oneOf)) {
for (const sub of schema.oneOf as JSONSchema[]) {
result.push(...collectRefs(sub, value));
}
return result;
}
// P2: allOf — each sub-schema applies to the same value
if (Array.isArray(schema.allOf)) {
for (const sub of schema.allOf as JSONSchema[]) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
import { createMemoryStore } from "./store.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { createMemoryStore } from "./store.js";
const T1 = "AAAAAAAAAAAAA";
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import type {
CasNode,
CasStore,
@@ -134,7 +134,7 @@ describe("Aggregate Store type", () => {
describe("source convention", () => {
test("types.ts uses 'type' not 'interface' for new types", () => {
const src = readFileSync(join(import.meta.dir, "types.ts"), "utf8");
const src = readFileSync(join(import.meta.dirname, "types.ts"), "utf8");
for (const name of ["CasStore", "VarStore", "TagStore"]) {
expect(src).toMatch(new RegExp(`export\\s+type\\s+${name}\\b`));
expect(src).not.toMatch(new RegExp(`interface\\s+${name}\\b`));
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import {
CasNodeNotFoundError,
InvalidVariableNameError,
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import type { Variable } from "./variable.js";
describe("Variable Type", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { createMemoryStore } from "./store.js";
import { wrapEnvelope } from "./wrap-envelope.js";
+37 -35
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { bootstrap } from "../src/bootstrap.js";
import { MemStore } from "../src/mem-store.js";
import type { JSONSchema } from "../src/schema.js";
@@ -258,169 +258,171 @@ describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
test("3.1: Reject schema with invalid type value", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () => putSchema(store, { type: "garbage" })).toThrow();
await expect(async () =>
putSchema(store, { type: "garbage" }),
).rejects.toThrow();
});
test("3.2: Reject schema with type as number", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, { type: 123 } as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.3: Reject schema with properties not an object", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
type: "object",
properties: "not-an-object",
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.4: Reject schema with required not an array", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
type: "object",
required: "name",
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.5: Reject schema with required containing non-strings", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
type: "object",
required: ["name", 123, true],
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.6: Reject schema with additionalProperties as string", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
type: "object",
additionalProperties: "yes",
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.7: Reject schema with anyOf not an array", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
anyOf: { type: "string" },
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.8: Reject schema with empty anyOf array", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () => putSchema(store, { anyOf: [] })).toThrow();
await expect(async () => putSchema(store, { anyOf: [] })).rejects.toThrow();
});
test("3.9: Reject schema with items not an object", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
type: "array",
items: "string",
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.10: Reject schema with format not a string", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
type: "string",
format: 123,
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.11: Reject schema with enum not an array", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
type: "string",
enum: "red",
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.12: Reject schema with empty enum array", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, { type: "string", enum: [] }),
).toThrow();
).rejects.toThrow();
});
test("3.13: Reject schema with title not a string", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
type: "string",
title: 123,
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.14: Reject schema with description not a string", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
type: "string",
description: ["not a string"],
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.15: Reject schema with unsupported $ref keyword", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
$ref: "#/definitions/user",
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.16: Reject completely invalid data (non-object)", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, "not-a-schema" as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
test("3.17: Reject nested invalid schema in properties", async () => {
const store = new MemStore();
bootstrap(store);
expect(async () =>
await expect(async () =>
putSchema(store, {
type: "object",
properties: {
name: { type: "invalid-type" },
},
} as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
});
@@ -641,9 +643,9 @@ describe("Test Suite 7: Meta-Schema Content Validation", () => {
expect(hash2).toBeTruthy();
// Invalid type (number)
expect(async () =>
await expect(async () =>
putSchema(store, { type: 123 } as unknown as JSONSchema),
).toThrow();
).rejects.toThrow();
});
});
+31 -28
View File
@@ -1,42 +1,45 @@
# @ocas/fs
## 0.2.0
## 0.4.0 — 2026-06-07
- Lazy loading: `FsStore` scans only filenames in `nodes/` at startup (no CBOR decoding), reads each node from disk on first `get()`. Startup is O(filenames) instead of O(decoded-bytes).
- Move CAS node files from store root into `nodes/` subdirectory. Pre-existing flat-layout stores are auto-migrated on first open.
## 0.3.0 — 2026-06-03
- Migrate from `better-sqlite3` to built-in `node:sqlite` — zero native addon dependencies.
## 0.2.2 — 2026-06-03
- Lint and format fixes.
## 0.2.1 — 2026-06-03
- Migrate var/tag store from JSONL to `better-sqlite3`.
- `openStore()` now returns unified `Store` with `cas`, `var`, `tag` sub-stores.
- Migrate runtime from Bun to Node.js + pnpm.
## 0.2.0 — 2026-06-02
### Breaking Changes
- `Store` is now `{ cas: CasStore, var: VarStore, tag: TagStore }` — all sub-stores accessed via properties
- `bootstrap(store)` and `putSchema(store, schema)` are now **synchronous** (were async)
- `VariableStore` class removed from `@ocas/core` — SQLite implementation moved to `@ocas/fs`
- `createVariableStore()` removed from `@ocas/core`
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally
- `@ocas/core` has zero `bun:sqlite` imports — pure TypeScript
- `ocas var tag` subcommand removed — use `ocas tag` / `ocas untag` instead
- `openStoreAndVarStore()` removed — use `openStore()` returning unified `Store`.
- `RenderOptions.varStore` removed — templates resolved via `store.var` internally.
### New Features
- `CasStore`, `VarStore`, `TagStore` sub-store types
- `TagStore` — first-class tags on any CAS node (not just variables)
- Top-level `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...` commands
- `ocas get` and `ocas var get` now include tag info in output
- `ocas list --tag` and `ocas var list --tag` filter support
- `var-store-helpers.ts` — shared validation/history logic
- `validation.ts` — shared `validateName()` exported from core
- SQLite-backed `VarStore` and `TagStore` implementations.
- `TagStore` — first-class tags on any CAS node.
- `var-store-helpers.ts` — shared validation/history logic.
## 0.1.2
## 0.1.2 — 2026-06-02
### Patch Changes
- Updated dependencies.
- Updated dependencies:
- @ocas/core@0.1.2
## 0.1.1 — 2026-06-02
## 0.1.1
- Updated dependencies.
### Patch Changes
## 0.1.0 — 2026-06-01
- Updated dependencies:
- @ocas/core@0.1.1
## 0.1.0
Initial release as `@ocas/fs`. Filesystem-backed CAS store with SQLite indexing and auto-bootstrap.
Initial release. Filesystem-backed CAS store with SQLite indexing and auto-bootstrap.
+7 -20
View File
@@ -4,7 +4,7 @@ Filesystem-backed CAS store.
## Overview
`@ocas/fs` implements a persistent `Store` on disk. Each node is stored as `<hash>.bin` (CBOR-encoded `CasNode`). A `_index/` directory maps type hashes to content hashes for `listByType`. Stores support bootstrap via the same `BOOTSTRAP_STORE` symbol as the in-memory implementation.
`@ocas/fs` implements a persistent `Store` backed by `node:sqlite` (`DatabaseSync`). Nodes are stored as CBOR blobs in SQLite tables. Stores support bootstrap via the same `BOOTSTRAP_STORE` symbol as the in-memory implementation.
Depends on `@ocas/core` for hashing, CBOR encoding, and types.
@@ -13,7 +13,7 @@ Depends on `@ocas/core` for hashing, CBOR encoding, and types.
## Installation
```bash
bun add @ocas/fs
pnpm add @ocas/fs
```
## API
@@ -21,19 +21,18 @@ bun add @ocas/fs
Exported from `src/index.ts`:
```typescript
function createFsStore(dir: string): BootstrapCapableStore;
function openStore(path: string): Promise<Store>;
```
Returns a `BootstrapCapableStore` from `@ocas/core`. The store loads existing `.bin` files on open and migrates or builds the type index on first use.
Returns a unified `Store` with `cas`, `var`, and `tag` sub-stores, backed by SQLite. Bootstraps automatically on open.
### Example
```typescript
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { putSchema } from "@ocas/core";
import { openStore } from "@ocas/fs";
const store = createFsStore("./my-cas-store");
await bootstrap(store);
const store = await openStore("./my-cas-store");
const typeHash = await putSchema(store, {
type: "object",
@@ -46,18 +45,6 @@ const hash = await store.put(typeHash, { id: "item-1" });
console.log(store.has(hash)); // true after restart if same dir
```
### On-disk layout
```
my-cas-store/
├── <hash>.bin # CBOR CasNode
├── _index/
│ └── <typeHash> # newline-separated content hashes
└── ...
```
Writes use atomic rename (`<hash>.tmp``<hash>.bin`).
## Internal Structure
| File | Purpose |
+16 -7
View File
@@ -1,6 +1,16 @@
{
"name": "@ocas/fs",
"version": "0.2.0",
"version": "0.4.0",
"description": "Filesystem-backed CAS store with SQLite",
"keywords": [
"cas",
"filesystem",
"sqlite",
"storage"
],
"engines": {
"node": ">=22.5.0"
},
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -14,13 +24,9 @@
"dist",
"src"
],
"scripts": {
"test": "bun test",
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
},
"dependencies": {
"cborg": "^4.2.3",
"@ocas/core": "0.2.0"
"@ocas/core": "workspace:*",
"cborg": "^4.2.3"
},
"repository": {
"type": "git",
@@ -30,5 +36,8 @@
"homepage": "https://github.com/shazhou-ww/ocas/tree/main/packages/fs",
"bugs": {
"url": "https://github.com/shazhou-ww/ocas/issues"
},
"devDependencies": {
"@types/node": "^25.9.1"
}
}
+1
View File
@@ -1 +1,2 @@
export { createSqliteVarStore } from "./sqlite-store.js";
export { createFsStore, openStore, prepareStore } from "./store.js";
+686
View File
@@ -0,0 +1,686 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import type {
CasStore,
Hash,
HistoryEntry,
ListOptions,
Tag,
TagOp,
TagStore,
Variable,
VarListOptions,
VarSetOptions,
VarStore,
} from "@ocas/core";
import {
addNameIndex,
checkTagLabelConflict,
extractSchema,
MAX_HISTORY,
removeNameIndex,
SchemaMismatchError,
VariableNotFoundError,
type VarRecord,
validateName,
varKey,
} from "@ocas/core";
function transaction<T>(db: DatabaseSync, fn: () => T): T {
db.exec("BEGIN");
try {
const r = fn();
db.exec("COMMIT");
return r;
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
}
const DB_FILE = "_store.db";
const VARS_FILE = "_vars.jsonl";
const TAGS_FILE = "_tags.jsonl";
function openDb(dir: string): DatabaseSync {
mkdirSync(dir, { recursive: true });
const db = new DatabaseSync(join(dir, DB_FILE));
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");
return db;
}
function initVarTables(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS vars (
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
tags TEXT NOT NULL DEFAULT '{}',
labels TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (name, schema)
);
CREATE TABLE IF NOT EXISTS var_history (
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
position INTEGER NOT NULL,
set_at INTEGER NOT NULL,
PRIMARY KEY (name, schema, position),
FOREIGN KEY (name, schema) REFERENCES vars(name, schema) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_vars_name ON vars(name);
CREATE INDEX IF NOT EXISTS idx_vars_created ON vars(created);
CREATE INDEX IF NOT EXISTS idx_vars_updated ON vars(updated);
CREATE INDEX IF NOT EXISTS idx_var_history_pos_desc ON var_history(name, schema, position DESC);
`);
}
function initTagTables(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS tags (
target TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT,
created INTEGER NOT NULL,
PRIMARY KEY (target, key)
);
CREATE INDEX IF NOT EXISTS idx_tags_key ON tags(key);
CREATE INDEX IF NOT EXISTS idx_tags_key_value ON tags(key, value);
`);
}
// ── JSONL migration ──
type StoredTag = {
key: string;
value: string | null;
target: Hash;
created: number;
};
function migrateJsonlVars(db: DatabaseSync, dir: string, _cas: CasStore): void {
const path = join(dir, VARS_FILE);
if (!existsSync(path)) return;
const records = new Map<string, VarRecord>();
const byName = new Map<string, Set<string>>();
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const rec = JSON.parse(line) as VarRecord & { __op?: string };
if (rec.__op === "remove") {
const k = varKey(rec.name, rec.schema);
records.delete(k);
removeNameIndex(byName, rec.name, k);
} else {
const k = varKey(rec.name, rec.schema);
records.set(k, rec);
addNameIndex(byName, rec.name, k);
}
} catch {
// skip malformed
}
}
if (records.size === 0) return;
const insertVar = db.prepare(`
INSERT OR REPLACE INTO vars (name, schema, value, created, updated, tags, labels)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertHistory = db.prepare(`
INSERT OR REPLACE INTO var_history (name, schema, value, position, set_at)
VALUES (?, ?, ?, ?, ?)
`);
transaction(db, () => {
for (const rec of records.values()) {
insertVar.run(
rec.name,
rec.schema,
rec.value,
rec.created,
rec.updated,
JSON.stringify(rec.tags),
JSON.stringify(rec.labels),
);
for (const h of rec.history) {
insertHistory.run(rec.name, rec.schema, h.value, h.position, h.setAt);
}
}
});
}
function migrateJsonlTags(db: DatabaseSync, dir: string): void {
const path = join(dir, TAGS_FILE);
if (!existsSync(path)) return;
const byTarget = new Map<Hash, Map<string, Tag>>();
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const ent = JSON.parse(line) as
| (StoredTag & { __op?: "set" | "untag" })
| { __op: "untag"; target: Hash; key: string };
if ((ent as { __op?: string }).__op === "untag") {
const e = ent as { target: Hash; key: string };
const tm = byTarget.get(e.target);
if (tm) {
tm.delete(e.key);
if (tm.size === 0) byTarget.delete(e.target);
}
} else {
const t = ent as StoredTag;
let tm = byTarget.get(t.target);
if (!tm) {
tm = new Map();
byTarget.set(t.target, tm);
}
tm.set(t.key, {
key: t.key,
value: t.value,
target: t.target,
created: t.created,
});
}
} catch {
// skip
}
}
if (byTarget.size === 0) return;
const insertTag = db.prepare(`
INSERT OR REPLACE INTO tags (target, key, value, created)
VALUES (?, ?, ?, ?)
`);
transaction(db, () => {
for (const tm of byTarget.values()) {
for (const tag of tm.values()) {
insertTag.run(tag.target, tag.key, tag.value, tag.created);
}
}
});
}
// ── Row helpers ──
function toVariable(row: Record<string, unknown>): Variable {
return {
name: row.name as string,
schema: row.schema as Hash,
value: row.value as Hash,
created: row.created as number,
updated: row.updated as number,
tags: JSON.parse(row.tags as string) as Record<string, string>,
labels: JSON.parse(row.labels as string) as string[],
};
}
function toHistoryEntry(r: Record<string, unknown>): HistoryEntry {
return {
value: r.value as Hash,
position: r.position as number,
setAt: r.set_at as number,
};
}
function toTag(r: Record<string, unknown>, target: Hash): Tag {
return {
key: r.key as string,
value: r.value as string | null,
target,
created: r.created as number,
};
}
// ── Main factory ──
export function createSqliteVarStore(
dir: string,
cas: CasStore,
): { var: VarStore; tag: TagStore; close: () => void } {
const db = openDb(dir);
initVarTables(db);
initTagTables(db);
// Migrate JSONL if present (one-time, idempotent)
migrateJsonlVars(db, dir, cas);
migrateJsonlTags(db, dir);
let closed = false;
// ── Prepared statements (var) ──
const stmtGetVar = db.prepare(
"SELECT * FROM vars WHERE name = ? AND schema = ?",
);
const stmtGetByName = db.prepare("SELECT * FROM vars WHERE name = ?");
const stmtInsertVar = db.prepare(`
INSERT INTO vars (name, schema, value, created, updated, tags, labels)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const stmtUpdateVar = db.prepare(`
UPDATE vars SET value = ?, updated = ?, tags = ?, labels = ?
WHERE name = ? AND schema = ?
`);
const stmtDeleteVar = db.prepare(
"DELETE FROM vars WHERE name = ? AND schema = ?",
);
const stmtDeleteVarByName = db.prepare("DELETE FROM vars WHERE name = ?");
const stmtInsertHistory = db.prepare(`
INSERT INTO var_history (name, schema, value, position, set_at)
VALUES (?, ?, ?, ?, ?)
`);
const stmtGetHistory = db.prepare(
"SELECT value, position, set_at FROM var_history WHERE name = ? AND schema = ? ORDER BY position DESC",
);
const stmtMaxPosition = db.prepare(
"SELECT MAX(position) as max_pos FROM var_history WHERE name = ? AND schema = ?",
);
const stmtDeleteOldHistory = db.prepare(
"DELETE FROM var_history WHERE name = ? AND schema = ? AND position NOT IN (SELECT position FROM var_history WHERE name = ? AND schema = ? ORDER BY position DESC LIMIT ?)",
);
// ── Prepared statements (tag) ──
const stmtUpsertTag = db.prepare(`
INSERT INTO tags (target, key, value, created) VALUES (?, ?, ?, ?)
ON CONFLICT(target, key) DO UPDATE SET value = excluded.value
`);
const stmtDeleteTag = db.prepare(
"DELETE FROM tags WHERE target = ? AND key = ?",
);
const stmtGetTagsByTarget = db.prepare(
"SELECT * FROM tags WHERE target = ? ORDER BY key",
);
const stmtGetTagsByKey = db.prepare(
"SELECT target, key, value, created FROM tags WHERE key = ? ORDER BY created ASC",
);
const stmtGetTagsByKeyValue = db.prepare(
"SELECT target, key, value, created FROM tags WHERE key = ? AND value = ? ORDER BY created ASC",
);
// ── Transactional helpers ──
function txnSetVar(
name: string,
schema: Hash,
hash: Hash,
now: number,
tagsJson: string,
labelsJson: string,
isNew: boolean,
valueChanged: boolean,
): void {
transaction(db, () => {
if (isNew) {
stmtInsertVar.run(name, schema, hash, now, now, tagsJson, labelsJson);
stmtInsertHistory.run(name, schema, hash, 0, now);
} else if (valueChanged) {
const maxRow = stmtMaxPosition.get(name, schema) as {
max_pos: number | null;
};
const nextPos = (maxRow.max_pos ?? -1) + 1;
stmtInsertHistory.run(name, schema, hash, nextPos, now);
stmtDeleteOldHistory.run(name, schema, name, schema, MAX_HISTORY);
stmtUpdateVar.run(hash, now, tagsJson, labelsJson, name, schema);
} else {
stmtUpdateVar.run(hash, now, tagsJson, labelsJson, name, schema);
}
});
}
function txnTagOps(target: Hash, operations: TagOp[], now: number): void {
transaction(db, () => {
for (const op of operations) {
if (op.op === "set") {
// Use ON CONFLICT to preserve created time — but we need existing created
const existing = db
.prepare("SELECT created FROM tags WHERE target = ? AND key = ?")
.get(target, op.key) as { created: number } | undefined;
const created = existing?.created ?? now;
stmtUpsertTag.run(target, op.key, op.value ?? null, created);
} else {
stmtDeleteTag.run(target, op.key);
}
}
});
}
function txnUntag(target: Hash, keys: string[]): void {
transaction(db, () => {
for (const k of keys) {
stmtDeleteTag.run(target, k);
}
});
}
// ── VarStore implementation ──
const varStore: VarStore = {
set(name: string, hash: Hash, options?: VarSetOptions): Variable {
validateName(name);
const schema = extractSchema(cas, hash);
const existing = stmtGetVar.get(name, schema) as
| Record<string, unknown>
| undefined;
const now = Date.now();
if (existing) {
const v = toVariable(existing);
const tags = options?.tags ?? v.tags;
const labels = options?.labels ?? v.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const valueChanged = v.value !== hash;
const newTags = options !== undefined ? tags : v.tags;
const newLabels = options !== undefined ? labels : v.labels;
if (valueChanged || options !== undefined) {
txnSetVar(
name,
schema,
hash,
valueChanged ? now : v.updated,
JSON.stringify(newTags),
JSON.stringify(newLabels),
false,
valueChanged,
);
}
return {
name,
schema,
value: hash,
created: v.created,
updated: valueChanged ? now : v.updated,
tags: { ...newTags },
labels: [...newLabels],
};
}
// New variable
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
checkTagLabelConflict(tags, labels);
txnSetVar(
name,
schema,
hash,
now,
JSON.stringify(tags),
JSON.stringify(labels),
true,
false,
);
return {
name,
schema,
value: hash,
created: now,
updated: now,
tags: { ...tags },
labels: [...labels],
};
},
get(name: string, schema?: Hash): Variable | null {
if (schema !== undefined) {
const row = stmtGetVar.get(name, schema) as
| Record<string, unknown>
| undefined;
return row ? toVariable(row) : null;
}
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
if (rows.length !== 1) return null;
return toVariable(rows[0]!);
},
remove(name: string, schema?: Hash): Variable[] {
if (schema !== undefined) {
const row = stmtGetVar.get(name, schema) as
| Record<string, unknown>
| undefined;
if (!row) return [];
const v = toVariable(row);
stmtDeleteVar.run(name, schema);
return [v];
}
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
if (rows.length === 0) return [];
const removed = rows.map(toVariable);
stmtDeleteVarByName.run(name);
return removed;
},
update(name: string, hash: Hash, options?: VarSetOptions): Variable {
validateName(name);
const newSchema = extractSchema(cas, hash);
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
if (rows.length === 0) throw new VariableNotFoundError(name, newSchema);
const existing = stmtGetVar.get(name, newSchema) as
| Record<string, unknown>
| undefined;
if (!existing) {
const first = toVariable(rows[0]!);
throw new SchemaMismatchError(first.schema, newSchema);
}
const v = toVariable(existing);
const now = Date.now();
const tags = options?.tags ?? v.tags;
const labels = options?.labels ?? v.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const valueChanged = v.value !== hash;
const newTags = options !== undefined ? tags : v.tags;
const newLabels = options !== undefined ? labels : v.labels;
if (valueChanged || options !== undefined) {
txnSetVar(
name,
newSchema,
hash,
valueChanged ? now : v.updated,
JSON.stringify(newTags),
JSON.stringify(newLabels),
false,
valueChanged,
);
}
return {
name,
schema: newSchema,
value: hash,
created: v.created,
updated: valueChanged ? now : v.updated,
tags: { ...newTags },
labels: [...newLabels],
};
},
list(options?: VarListOptions): Variable[] {
if (
options?.namePrefix !== undefined &&
options?.exactName !== undefined
) {
throw new Error(
"namePrefix and exactName are mutually exclusive - cannot specify both",
);
}
const limit = options?.limit;
if (limit !== undefined && limit <= 0) return [];
// Build dynamic query
const conditions: string[] = [];
const params: (string | number | null)[] = [];
if (options?.exactName !== undefined) {
conditions.push("name = ?");
params.push(options.exactName);
}
if (options?.namePrefix !== undefined) {
conditions.push("name LIKE ? ESCAPE '\\'");
const escaped = options.namePrefix
.replace(/\\/g, "\\\\")
.replace(/%/g, "\\%")
.replace(/_/g, "\\_");
params.push(`${escaped}%`);
}
if (options?.schema !== undefined) {
conditions.push("schema = ?");
params.push(options.schema);
}
const sortCol = options?.sort === "updated" ? "updated" : "created";
const sortDir = options?.desc ? "DESC" : "ASC";
const where =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
// Post-filter by tags and labels (stored as JSON)
const filterTags = options?.tags ?? {};
const filterLabels = options?.labels ?? [];
const needsPostFilter =
Object.keys(filterTags).length > 0 || filterLabels.length > 0;
// When post-filtering, fetch all matching rows (no SQL LIMIT)
// then apply limit/offset after filtering
let sql: string;
if (needsPostFilter) {
sql = `SELECT * FROM vars ${where} ORDER BY ${sortCol} ${sortDir}, name ASC`;
} else {
sql = `SELECT * FROM vars ${where} ORDER BY ${sortCol} ${sortDir}, name ASC`;
if (limit !== undefined || (options?.offset ?? 0) > 0) {
sql += ` LIMIT ${limit ?? -1} OFFSET ${options?.offset ?? 0}`;
}
}
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
if (!needsPostFilter) return rows.map(toVariable);
let results: Variable[] = [];
for (const row of rows) {
const v = toVariable(row);
let ok = true;
for (const [tk, tv] of Object.entries(filterTags)) {
if (v.tags[tk] !== tv) {
ok = false;
break;
}
}
if (!ok) continue;
for (const lb of filterLabels) {
if (!v.labels.includes(lb)) {
ok = false;
break;
}
}
if (ok) results.push(v);
}
// Apply limit/offset after post-filter
const offset = options?.offset ?? 0;
if (offset > 0) results = results.slice(offset);
if (limit !== undefined) results = results.slice(0, limit);
return results;
},
history(name: string, schema?: Hash): HistoryEntry[] {
if (schema !== undefined) {
return (
stmtGetHistory.all(name, schema) as Record<string, unknown>[]
).map(toHistoryEntry);
}
const vars = stmtGetByName.all(name) as Record<string, unknown>[];
if (vars.length !== 1) return [];
const v = vars[0]!;
return (
stmtGetHistory.all(v.name as string, v.schema as string) as Record<
string,
unknown
>[]
).map(toHistoryEntry);
},
close(): void {
if (closed) return;
closed = true;
db.close();
},
};
// ── TagStore implementation ──
const tagStore: TagStore = {
tag(target: Hash, operations: TagOp[]): Tag[] {
const now = Date.now();
txnTagOps(target, operations, now);
return (stmtGetTagsByTarget.all(target) as Record<string, unknown>[]).map(
(r) => toTag(r, target),
);
},
untag(target: Hash, keys: string[]): void {
txnUntag(target, keys);
},
tags(target: Hash): Tag[] {
return (stmtGetTagsByTarget.all(target) as Record<string, unknown>[]).map(
(r) => toTag(r, target),
);
},
listByTag(tag: string, options?: ListOptions): Hash[] {
let key = tag;
let value: string | null | undefined;
const eqIdx = tag.indexOf("=");
if (eqIdx >= 0) {
key = tag.slice(0, eqIdx);
value = tag.slice(eqIdx + 1);
}
// Build SQL with sort/limit/offset pushed down
const sortCol = "created"; // tags only have created
const sortDir = options?.desc ? "DESC" : "ASC";
const offset = options?.offset ?? 0;
const limit = options?.limit;
let sql: string;
const params: (string | number | null)[] = [key];
if (value !== undefined) {
sql = `SELECT target FROM tags WHERE key = ? AND value = ? ORDER BY ${sortCol} ${sortDir}`;
params.push(value);
} else {
sql = `SELECT target FROM tags WHERE key = ? ORDER BY ${sortCol} ${sortDir}`;
}
if (limit !== undefined || offset > 0) {
sql += ` LIMIT ${limit ?? -1} OFFSET ${offset}`;
}
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
return rows.map((r) => r.target as Hash);
},
};
return {
var: varStore,
tag: tagStore,
close: () => {
if (closed) return;
closed = true;
db.close();
},
};
}
+539 -2
View File
@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
@@ -17,6 +18,7 @@ import {
computeSelfHash,
verify,
} from "@ocas/core";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createFsStore, openStore } from "./store.js";
@@ -67,7 +69,7 @@ describe("createFsStore – init and bootstrap", () => {
const h2 = bootstrap(store);
expect(h1).toEqual(h2);
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(32);
});
});
@@ -588,3 +590,538 @@ describe("openStore – Store shape", () => {
expect(typeof store.tag.listByTag).toBe("function");
});
});
// ──────────────────────────────────────────────────────────────────────────────
// nodes/ subdirectory layout (#84)
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – nodes/ subdirectory layout", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// A. New layout – nodes written to nodes/ subdirectory
test("A1. put() writes .bin file to dir/nodes/, not dir/", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
const hash = await store.put(typeHash, { x: 1 });
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(true);
expect(existsSync(join(dir, `${hash}.bin`))).toBe(false);
});
test("A2. putSelfReferencing (BOOTSTRAP_STORE) writes to dir/nodes/", async () => {
const store = createFsStore(dir);
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(true);
expect(existsSync(join(dir, `${hash}.bin`))).toBe(false);
});
test("A3. nodes/ directory auto-created on first put", async () => {
expect(existsSync(join(dir, "nodes"))).toBe(false);
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
await store.put(typeHash, { x: 1 });
expect(existsSync(join(dir, "nodes"))).toBe(true);
expect(statSync(join(dir, "nodes")).isDirectory()).toBe(true);
});
// B. Round-trip with new layout
test("B1. second createFsStore instance reads nodes from dir/nodes/", async () => {
const typeHash = await computeSelfHash({ name: "B1" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { msg: "hello" });
const h2 = await store1.put(typeHash, { msg: "world" });
// Confirm files are in nodes/, not root
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
expect(existsSync(join(dir, "nodes", `${h2}.bin`))).toBe(true);
const store2 = createFsStore(dir);
expect(store2.has(h1)).toBe(true);
expect(store2.has(h2)).toBe(true);
expect(store2.listByType(typeHash)).toHaveLength(2);
});
test("B2. openStore round-trip: bootstrap + put + reload all intact", async () => {
const store1 = await openStore(dir);
const schemas1 = bootstrap(store1);
const schemaHash = schemas1["@ocas/schema"] ?? "";
const typeHash = await computeSelfHash({ name: "B2" });
const userHash = store1.cas.put(typeHash, { x: 42 });
// All node files should be in nodes/
const nodeEntries = readdirSync(join(dir, "nodes"));
expect(nodeEntries.some((e) => e === `${schemaHash}.bin`)).toBe(true);
expect(nodeEntries.some((e) => e === `${userHash}.bin`)).toBe(true);
const store2 = await openStore(dir);
expect(store2.cas.has(schemaHash)).toBe(true);
expect(store2.cas.has(userHash)).toBe(true);
});
// C. Migration from old flat layout
test("C1. old-layout .bin files in dir/ root are moved to dir/nodes/ on createFsStore", async () => {
// Manually build an "old" layout by writing nodes via a fresh store first
// then renaming files from nodes/ back to root, simulating pre-#84 stores.
const typeHash = await computeSelfHash({ name: "C1" });
const tmp = createFsStore(dir);
const h1 = await tmp.put(typeHash, { i: 1 });
const h2 = await tmp.put(typeHash, { i: 2 });
// Simulate old flat layout: move .bin files from nodes/ to root
const nodesDir = join(dir, "nodes");
for (const f of readdirSync(nodesDir)) {
renameSync(join(nodesDir, f), join(dir, f));
}
rmSync(nodesDir, { recursive: true, force: true });
expect(existsSync(join(dir, `${h1}.bin`))).toBe(true);
expect(existsSync(join(dir, `${h2}.bin`))).toBe(true);
expect(existsSync(nodesDir)).toBe(false);
// Now open the store; migration should run
const _store = createFsStore(dir);
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
expect(existsSync(join(dir, "nodes", `${h2}.bin`))).toBe(true);
});
test("C2. after migration, no .bin files remain in dir/ root", async () => {
const typeHash = await computeSelfHash({ name: "C2" });
const tmp = createFsStore(dir);
await tmp.put(typeHash, { i: 1 });
await tmp.put(typeHash, { i: 2 });
// Simulate old flat layout
const nodesDir = join(dir, "nodes");
for (const f of readdirSync(nodesDir)) {
renameSync(join(nodesDir, f), join(dir, f));
}
rmSync(nodesDir, { recursive: true, force: true });
const _store = createFsStore(dir);
const rootEntries = readdirSync(dir);
const binFilesInRoot = rootEntries.filter((e) => e.endsWith(".bin"));
expect(binFilesInRoot).toEqual([]);
});
test("C3. after migration, all nodes accessible via get() and listByType()", async () => {
const typeHash = await computeSelfHash({ name: "C3" });
const tmp = createFsStore(dir);
const h1 = await tmp.put(typeHash, { i: 1 });
const h2 = await tmp.put(typeHash, { i: 2 });
const h3 = await tmp.put(typeHash, { i: 3 });
// Simulate old flat layout: move .bin to root, drop _index so we re-build
const nodesDir = join(dir, "nodes");
for (const f of readdirSync(nodesDir)) {
renameSync(join(nodesDir, f), join(dir, f));
}
rmSync(nodesDir, { recursive: true, force: true });
rmSync(join(dir, "_index"), { recursive: true, force: true });
const store = createFsStore(dir);
expect(store.has(h1)).toBe(true);
expect(store.has(h2)).toBe(true);
expect(store.has(h3)).toBe(true);
const listed = store.listByType(typeHash).map((e) => e.hash);
expect(listed).toHaveLength(3);
expect(listed).toContain(h1);
expect(listed).toContain(h2);
expect(listed).toContain(h3);
});
test("C4. migration is idempotent: re-opening already-migrated store is no-op", async () => {
const typeHash = await computeSelfHash({ name: "C4" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { i: 1 });
await store1.put(typeHash, { i: 2 });
const nodesDirBefore = readdirSync(join(dir, "nodes")).sort();
const rootEntriesBefore = readdirSync(dir).sort();
// Re-open — should be a no-op (no migration occurs)
const _store2 = createFsStore(dir);
const nodesDirAfter = readdirSync(join(dir, "nodes")).sort();
const rootEntriesAfter = readdirSync(dir).sort();
expect(nodesDirAfter).toEqual(nodesDirBefore);
expect(rootEntriesAfter).toEqual(rootEntriesBefore);
// Sanity: file from before migration is still in nodes/
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
});
test("C5. openStore on old layout: migrates, bootstraps, put/get all work", async () => {
// Build an "old" store: bootstrap then move .bin to root
const store1 = await openStore(dir);
const schemas1 = bootstrap(store1);
const schemaHash = schemas1["@ocas/schema"] ?? "";
const nodesDir = join(dir, "nodes");
for (const f of readdirSync(nodesDir)) {
renameSync(join(nodesDir, f), join(dir, f));
}
rmSync(nodesDir, { recursive: true, force: true });
expect(existsSync(join(dir, `${schemaHash}.bin`))).toBe(true);
// Re-open with openStore — should migrate + bootstrap idempotently
const store2 = await openStore(dir);
expect(store2.cas.has(schemaHash)).toBe(true);
expect(existsSync(join(dir, "nodes", `${schemaHash}.bin`))).toBe(true);
expect(existsSync(join(dir, `${schemaHash}.bin`))).toBe(false);
// put/get works after migration
const typeHash = await computeSelfHash({ name: "C5" });
const newHash = store2.cas.put(typeHash, { hello: "world" });
expect(store2.cas.has(newHash)).toBe(true);
expect(existsSync(join(dir, "nodes", `${newHash}.bin`))).toBe(true);
});
// D. Delete
test("D1. delete() removes dir/nodes/<hash>.bin (not dir/<hash>.bin)", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "D1" });
const hash = await store.put(typeHash, { x: 1 });
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(true);
const removed = store.delete(hash);
expect(removed).toBe(true);
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(false);
expect(existsSync(join(dir, `${hash}.bin`))).toBe(false);
expect(store.has(hash)).toBe(false);
});
// E. Metadata location unchanged
test("E1. _index/ stays in dir/ (not inside nodes/)", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "E1" });
await store.put(typeHash, { x: 1 });
expect(existsSync(join(dir, "_index"))).toBe(true);
expect(statSync(join(dir, "_index")).isDirectory()).toBe(true);
expect(existsSync(join(dir, "nodes", "_index"))).toBe(false);
});
test("E2. _store.db stays in dir/ (not inside nodes/)", async () => {
const store = await openStore(dir);
const typeHash = await computeSelfHash({ name: "E2" });
store.cas.put(typeHash, { x: 1 });
expect(existsSync(join(dir, "_store.db"))).toBe(true);
expect(existsSync(join(dir, "nodes", "_store.db"))).toBe(false);
});
// F. Index rebuild with new layout
test("F1. removing _index/ then re-opening rebuilds index from dir/nodes/ .bin files", async () => {
const typeHash = await computeSelfHash({ name: "F1" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { a: 1 });
const h2 = await store1.put(typeHash, { a: 2 });
rmSync(join(dir, "_index"), { recursive: true, force: true });
expect(existsSync(join(dir, "_index"))).toBe(false);
const store2 = createFsStore(dir);
const list = store2.listByType(typeHash).map((e) => e.hash);
expect(list).toHaveLength(2);
expect(list).toContain(h1);
expect(list).toContain(h2);
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
// sanity: .bin files still in nodes/
expect(existsSync(join(dir, "nodes", `${h1}.bin`))).toBe(true);
expect(existsSync(join(dir, "nodes", `${h2}.bin`))).toBe(true);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Lazy loading (#85)
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – lazy loading (#85)", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("L1. createFsStore does NOT CBOR-decode nodes at startup", async () => {
const typeHash = await computeSelfHash({ name: "L1" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { i: 1 });
const h2 = await store1.put(typeHash, { i: 2 });
const h3 = await store1.put(typeHash, { i: 3 });
// Corrupt h2 by overwriting its .bin file with garbage CBOR
const corruptedPath = join(dir, "nodes", `${h2}.bin`);
writeFileSync(corruptedPath, Buffer.from([0xff, 0xfe, 0xfd, 0xfc]));
// Opening the store should NOT throw, even though h2 is corrupted —
// because nothing is decoded at startup.
const store2 = createFsStore(dir);
// has() should return true for all three (filename-based)
expect(store2.has(h1)).toBe(true);
expect(store2.has(h2)).toBe(true);
expect(store2.has(h3)).toBe(true);
// listAll() reads filenames, so all three appear
const all = store2.listAll();
expect(all).toContain(h1);
expect(all).toContain(h2);
expect(all).toContain(h3);
// Non-corrupted nodes load fine
expect(store2.get(h1)).not.toBeNull();
expect(store2.get(h3)).not.toBeNull();
// Corrupted node fails to load (returns null)
expect(store2.get(h2)).toBeNull();
});
test("L2. get() loads node from disk on demand (cache miss)", async () => {
const typeHash = await computeSelfHash({ name: "L2" });
const store1 = createFsStore(dir);
const hash = await store1.put(typeHash, { value: 42, label: "answer" });
const original = store1.get(hash) as CasNode;
// Lazy-load instance
const store2 = createFsStore(dir);
const loaded1 = store2.get(hash) as CasNode;
expect(loaded1.type).toBe(typeHash);
expect(loaded1.payload).toEqual({ value: 42, label: "answer" });
expect(loaded1.timestamp).toBe(original.timestamp);
// Second get should return the same data (from cache)
const loaded2 = store2.get(hash) as CasNode;
expect(loaded2).toEqual(loaded1);
});
test("L3. has() works without loading node data", async () => {
const typeHash = await computeSelfHash({ name: "L3" });
const store1 = createFsStore(dir);
const hashes: string[] = [];
for (let i = 0; i < 5; i++) {
hashes.push(await store1.put(typeHash, { i }));
}
const store2 = createFsStore(dir);
for (const hash of hashes) {
expect(store2.has(hash)).toBe(true);
}
// Non-existent hash returns false
expect(store2.has("0000000000000")).toBe(false);
});
test("L4. listAll() returns hashes from filenames without decoding", async () => {
const typeHash = await computeSelfHash({ name: "L4" });
const store1 = createFsStore(dir);
const realHashes: string[] = [];
for (let i = 0; i < 3; i++) {
realHashes.push(await store1.put(typeHash, { i }));
}
// Add a corrupted .bin file with valid filename but garbage content
const corruptedHash = "ABCDEFGHJKMNP";
writeFileSync(
join(dir, "nodes", `${corruptedHash}.bin`),
Buffer.from([0xff, 0xee, 0xdd]),
);
const store2 = createFsStore(dir);
const all = store2.listAll();
expect(all).toHaveLength(realHashes.length + 1);
for (const h of realHashes) {
expect(all).toContain(h);
}
expect(all).toContain(corruptedHash);
// Real nodes still readable
for (const h of realHashes) {
expect(store2.get(h)).not.toBeNull();
}
// Corrupted one returns null
expect(store2.get(corruptedHash)).toBeNull();
});
test("L5. put() makes node immediately available without re-reading disk", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "L5" });
const hash = await store.put(typeHash, { written: true });
// Immediately available via get(), has(), and listAll()
const node = store.get(hash) as CasNode;
expect(node.type).toBe(typeHash);
expect(node.payload).toEqual({ written: true });
expect(store.has(hash)).toBe(true);
expect(store.listAll()).toContain(hash);
});
test("L6. delete() removes node from cache and disk", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "L6" });
const hash = await store.put(typeHash, { temporary: true });
// populate cache by getting once
expect(store.get(hash)).not.toBeNull();
expect(store.delete(hash)).toBe(true);
expect(store.get(hash)).toBeNull();
expect(store.has(hash)).toBe(false);
expect(store.listAll()).not.toContain(hash);
expect(existsSync(join(dir, "nodes", `${hash}.bin`))).toBe(false);
});
test("L7. listByType works with lazy loading (loads timestamps on demand)", async () => {
const typeA = await computeSelfHash({ name: "typeA-L7" });
const typeB = await computeSelfHash({ name: "typeB-L7" });
const store1 = createFsStore(dir);
const aHashes: string[] = [];
for (let i = 0; i < 3; i++) {
aHashes.push(await store1.put(typeA, { i }));
}
const bHashes: string[] = [];
for (let i = 0; i < 2; i++) {
bHashes.push(await store1.put(typeB, { i }));
}
const store2 = createFsStore(dir);
const aList = store2.listByType(typeA);
expect(aList).toHaveLength(3);
for (const e of aList) {
expect(aHashes).toContain(e.hash);
expect(typeof e.created).toBe("number");
expect(e.created).toBeGreaterThan(0);
}
const bList = store2.listByType(typeB);
expect(bList).toHaveLength(2);
expect(store2.listByType("0000000000000")).toEqual([]);
});
test("L8. listMeta works with lazy loading", async () => {
const store1 = createFsStore(dir);
const m1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L8a" });
const m2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L8b" });
const store2 = createFsStore(dir);
const meta = store2.listMeta();
const metaHashes = meta.map((e) => e.hash);
expect(metaHashes).toHaveLength(2);
expect(metaHashes).toContain(m1);
expect(metaHashes).toContain(m2);
for (const e of meta) {
expect(typeof e.created).toBe("number");
expect(e.created).toBeGreaterThan(0);
}
});
test("L9. listSchemas works with lazy loading", async () => {
const store1 = createFsStore(dir);
const m = await store1[BOOTSTRAP_STORE]({ type: "object" });
const s1 = await store1.put(m, { type: "string" });
const s2 = await store1.put(m, { type: "number" });
const store2 = createFsStore(dir);
const schemas = store2.listSchemas().map((e) => e.hash);
expect(schemas).toHaveLength(3);
expect(schemas).toContain(m);
expect(schemas).toContain(s1);
expect(schemas).toContain(s2);
});
test("L10. index migration still works with lazy loading", async () => {
const typeHash = await computeSelfHash({ name: "L10" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { i: 1 });
const h2 = await store1.put(typeHash, { i: 2 });
rmSync(join(dir, "_index"), { recursive: true, force: true });
// Re-open: should rebuild type index by scanning + decoding nodes on disk
const store2 = createFsStore(dir);
const list = store2.listByType(typeHash).map((e) => e.hash);
expect(list).toHaveLength(2);
expect(list).toContain(h1);
expect(list).toContain(h2);
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
// Re-open again: index already on disk, no re-scan needed
const store3 = createFsStore(dir);
const list3 = store3.listByType(typeHash).map((e) => e.hash);
expect(list3).toHaveLength(2);
});
test("L11. meta migration still works with lazy loading", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L11a" });
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "L11b" });
const metaPath = join(dir, "_index", "_meta");
rmSync(metaPath, { force: true });
expect(existsSync(metaPath)).toBe(false);
const store2 = createFsStore(dir);
const meta = store2.listMeta().map((e) => e.hash);
expect(meta).toHaveLength(2);
expect(meta).toContain(h1);
expect(meta).toContain(h2);
expect(existsSync(metaPath)).toBe(true);
});
test("L12. bootstrap round-trip works with lazy store", async () => {
const store1 = await openStore(dir);
const schemas1 = bootstrap(store1);
const typeHash = await computeSelfHash({ name: "L12-user" });
const userHash = store1.cas.put(typeHash, { user: "data" });
const store2 = await openStore(dir);
// All bootstrap schemas accessible
for (const name of [
"@ocas/schema",
"@ocas/string",
"@ocas/number",
"@ocas/object",
"@ocas/array",
"@ocas/bool",
]) {
const h = schemas1[name] as string;
expect(store2.cas.has(h)).toBe(true);
expect(store2.cas.get(h)).not.toBeNull();
}
// User data still accessible
expect(store2.cas.has(userHash)).toBe(true);
const userNode = store2.cas.get(userHash) as CasNode;
expect(userNode.payload).toEqual({ user: "data" });
});
});
+146 -74
View File
@@ -27,32 +27,68 @@ import {
type Store,
} from "@ocas/core";
import { decode } from "cborg";
import { createFsTagStore, createFsVarStoreFor } from "./var-store.js";
import { createSqliteVarStore } from "./sqlite-store.js";
const INDEX_DIR = "_index";
const META_FILE = "_meta";
const NODES_DIR = "nodes";
// Initialise the xxhash WASM instance once at module load so the FS CAS
// store can use the synchronous hashing functions.
await initHasher();
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
/**
* Migrate any pre-#84 flat-layout `.bin` files at the store root into the
* `nodes/` subdirectory. Idempotent does nothing if no `.bin` files are
* present at the root.
*/
function migrateFlatLayoutToNodes(dir: string): void {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
const binFiles = entries.filter((name) => name.endsWith(".bin"));
if (binFiles.length === 0) return;
const nodesDir = join(dir, NODES_DIR);
mkdirSync(nodesDir, { recursive: true });
for (const name of binFiles) {
renameSync(join(dir, name), join(nodesDir, name));
}
}
/**
* Scan `nodes/` directory for `.bin` filenames and return the set of hashes
* present on disk. Does NOT read or decode any node content this is the
* cheap O(n) startup operation that replaces the legacy full-load.
*/
function loadHashSet(dir: string): Set<Hash> {
const hashes = new Set<Hash>();
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return hashes;
}
for (const name of entries) {
if (!name.endsWith(".bin")) continue;
const hash = name.slice(0, -4) as Hash;
try {
const buf = readFileSync(join(dir, name));
const node = decode(new Uint8Array(buf)) as CasNode;
data.set(hash, node);
} catch {
// skip corrupted files
}
hashes.add(name.slice(0, -4) as Hash);
}
return hashes;
}
/**
* Read and CBOR-decode a single node from disk. Returns `null` if the file
* is missing or its content is corrupted.
*/
function readNodeFromDisk(nodesDir: string, hash: Hash): CasNode | null {
try {
const buf = readFileSync(join(nodesDir, `${hash}.bin`));
return decode(new Uint8Array(buf)) as CasNode;
} catch {
return null;
}
}
@@ -80,9 +116,19 @@ function loadTypeIndex(indexDir: string): Map<Hash, Hash[]> {
return typeIndex;
}
function buildTypeIndexFromNodes(data: Map<Hash, CasNode>): Map<Hash, Hash[]> {
/**
* Migration helper: scan all `.bin` files on disk, decoding each one to read
* its `type` field, and rebuild the type index. Used only when `_index/` is
* missing a one-time cost.
*/
function buildTypeIndexFromDisk(
nodesDir: string,
hashSet: Set<Hash>,
): Map<Hash, Hash[]> {
const typeIndex = new Map<Hash, Hash[]>();
for (const [hash, node] of data) {
for (const hash of hashSet) {
const node = readNodeFromDisk(nodesDir, hash);
if (!node) continue;
const list = typeIndex.get(node.type) ?? [];
list.push(hash);
typeIndex.set(node.type, list);
@@ -100,11 +146,12 @@ function writeTypeIndex(indexDir: string, typeIndex: Map<Hash, Hash[]>): void {
function loadOrMigrateTypeIndex(
dir: string,
data: Map<Hash, CasNode>,
nodesDir: string,
hashSet: Set<Hash>,
): Map<Hash, Hash[]> {
const indexDir = join(dir, INDEX_DIR);
if (!existsSync(indexDir)) {
const typeIndex = buildTypeIndexFromNodes(data);
const typeIndex = buildTypeIndexFromDisk(nodesDir, hashSet);
if (typeIndex.size > 0) {
writeTypeIndex(indexDir, typeIndex);
}
@@ -115,7 +162,8 @@ function loadOrMigrateTypeIndex(
function loadOrMigrateMetaSet(
dir: string,
data: Map<Hash, CasNode>,
nodesDir: string,
hashSet: Set<Hash>,
): Set<Hash> {
const indexDir = join(dir, INDEX_DIR);
const metaPath = join(indexDir, META_FILE);
@@ -127,10 +175,11 @@ function loadOrMigrateMetaSet(
return new Set();
}
}
// Migration: scan loaded nodes for self-referencing nodes (type === hash)
// Migration: scan nodes on disk for self-referencing nodes (type === hash)
const metaSet = new Set<Hash>();
for (const [hash, node] of data) {
if (node.type === hash) {
for (const hash of hashSet) {
const node = readNodeFromDisk(nodesDir, hash);
if (node && node.type === hash) {
metaSet.add(hash);
}
}
@@ -181,18 +230,6 @@ function appendToTypeIndex(
typeIndex.set(type, list);
}
function hashesToEntries(
data: Map<Hash, CasNode>,
hashes: Iterable<Hash>,
): ListEntry[] {
const result: ListEntry[] = [];
for (const h of hashes) {
const node = data.get(h);
if (node) result.push(casListEntry(h, node.timestamp));
}
return result;
}
/**
* The CAS sub-store of an FS-backed `Store` also satisfies the legacy
* `BootstrapCapableStore` interface so `bootstrap()` can run against it.
@@ -203,21 +240,50 @@ export type FsCasStore = BootstrapCapableStore & {
};
export function createFsStore(dir: string): FsCasStore {
const data = new Map<Hash, CasNode>();
loadDir(dir, data);
// Migrate any pre-#84 flat-layout .bin files at the root into nodes/.
migrateFlatLayoutToNodes(dir);
const nodesDir = join(dir, NODES_DIR);
// Lazy loading (#85): only scan filenames at startup — do NOT decode.
const hashSet = loadHashSet(nodesDir);
// In-memory cache of decoded nodes. Populated on first get() of each hash.
const cache = new Map<Hash, CasNode>();
const indexDir = join(dir, INDEX_DIR);
const typeIndex = loadOrMigrateTypeIndex(dir, data);
const metaSet = loadOrMigrateMetaSet(dir, data);
const typeIndex = loadOrMigrateTypeIndex(dir, nodesDir, hashSet);
const metaSet = loadOrMigrateMetaSet(dir, nodesDir, hashSet);
/**
* Look up a node by hash, loading from disk on cache miss. Returns `null`
* if the hash is unknown or the file is corrupted.
*/
function loadNode(hash: Hash): CasNode | null {
const cached = cache.get(hash);
if (cached) return cached;
if (!hashSet.has(hash)) return null;
const node = readNodeFromDisk(nodesDir, hash);
if (node) cache.set(hash, node);
return node;
}
function hashesToEntries(hashes: Iterable<Hash>): ListEntry[] {
const result: ListEntry[] = [];
for (const h of hashes) {
const node = loadNode(h);
if (node) result.push(casListEntry(h, node.timestamp));
}
return result;
}
function putSelfReferencing(payload: unknown): Hash {
const hash = computeSelfHashSync(payload);
if (!data.has(hash)) {
if (!hashSet.has(hash)) {
const node: CasNode = { type: hash, payload, timestamp: Date.now() };
data.set(hash, node);
hashSet.add(hash);
cache.set(hash, node);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `${hash}.tmp`);
const dest = join(dir, `${hash}.bin`);
mkdirSync(nodesDir, { recursive: true });
const tmp = join(nodesDir, `${hash}.tmp`);
const dest = join(nodesDir, `${hash}.bin`);
writeFileSync(
tmp,
cborEncode({ type: hash, payload, timestamp: node.timestamp }),
@@ -234,17 +300,18 @@ export function createFsStore(dir: string): FsCasStore {
put(typeHash: Hash, payload: unknown): Hash {
const hash = computeHashSync(typeHash, payload);
if (!data.has(hash)) {
if (!hashSet.has(hash)) {
const node: CasNode = {
type: typeHash,
payload,
timestamp: Date.now(),
};
data.set(hash, node);
hashSet.add(hash);
cache.set(hash, node);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `${hash}.tmp`);
const dest = join(dir, `${hash}.bin`);
mkdirSync(nodesDir, { recursive: true });
const tmp = join(nodesDir, `${hash}.tmp`);
const dest = join(nodesDir, `${hash}.bin`);
writeFileSync(
tmp,
cborEncode({ type: typeHash, payload, timestamp: node.timestamp }),
@@ -258,25 +325,25 @@ export function createFsStore(dir: string): FsCasStore {
},
get(hash: Hash): CasNode | null {
return data.get(hash) ?? null;
return loadNode(hash);
},
has(hash: Hash): boolean {
return data.has(hash);
return hashSet.has(hash);
},
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
const list = typeIndex.get(typeHash);
if (!list) return [];
return applyListOptions(hashesToEntries(data, list), options);
return applyListOptions(hashesToEntries(list), options);
},
listAll(): Hash[] {
return Array.from(data.keys());
return Array.from(hashSet);
},
listMeta(options?: ListOptions): ListEntry[] {
return applyListOptions(hashesToEntries(data, metaSet), options);
return applyListOptions(hashesToEntries(metaSet), options);
},
listSchemas(options?: ListOptions): ListEntry[] {
@@ -288,38 +355,42 @@ export function createFsStore(dir: string): FsCasStore {
for (const h of list) result.add(h);
}
}
return applyListOptions(hashesToEntries(data, result), options);
return applyListOptions(hashesToEntries(result), options);
},
delete(hash: Hash): boolean {
const node = data.get(hash);
if (!node) return false;
data.delete(hash);
if (!hashSet.has(hash)) return false;
// Need the node's type to clean up the type index. Lazy-load if needed.
const node = loadNode(hash);
hashSet.delete(hash);
cache.delete(hash);
// Delete file
try {
unlinkSync(join(dir, `${hash}.bin`));
unlinkSync(join(nodesDir, `${hash}.bin`));
} catch {
// ignore if file doesn't exist
}
// Remove from type index
const list = typeIndex.get(node.type);
if (list) {
const idx = list.indexOf(hash);
if (idx !== -1) {
list.splice(idx, 1);
}
if (list.length === 0) {
typeIndex.delete(node.type);
// Delete empty index file
try {
unlinkSync(join(indexDir, node.type));
} catch {
// ignore
// Remove from type index (only if we could decode the node)
if (node) {
const list = typeIndex.get(node.type);
if (list) {
const idx = list.indexOf(hash);
if (idx !== -1) {
list.splice(idx, 1);
}
if (list.length === 0) {
typeIndex.delete(node.type);
// Delete empty index file
try {
unlinkSync(join(indexDir, node.type));
} catch {
// ignore
}
} else {
// Rewrite index file
const body = `${list.join("\n")}\n`;
writeFileSync(join(indexDir, node.type), body, "utf8");
}
} else {
// Rewrite index file
const body = `${list.join("\n")}\n`;
writeFileSync(join(indexDir, node.type), body, "utf8");
}
}
// Remove from meta set if applicable
@@ -393,10 +464,11 @@ export async function prepareStore(dir: string): Promise<FsCasStore> {
*/
export async function openStore(dir: string): Promise<Store> {
const cas = await prepareStore(dir);
const sqlite = createSqliteVarStore(dir, cas);
const ocas: Store = {
cas,
var: createFsVarStoreFor(dir, cas),
tag: createFsTagStore(dir),
var: sqlite.var,
tag: sqlite.tag,
};
bootstrap(ocas);
return ocas;
+6 -15
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { openStore } from "./store.js";
const T1 = "AAAAAAAAAAAAA";
@@ -16,7 +16,7 @@ describe("FsTagStore", () => {
rmSync(dir, { recursive: true, force: true });
});
test("B1. set tag with key/value round-trip + JSONL persisted", async () => {
test("B1. set tag with key/value round-trip + SQLite persisted", async () => {
const store = await openStore(dir);
const result = store.tag.tag(T1, [
{ op: "set", key: "env", value: "prod" },
@@ -26,17 +26,8 @@ describe("FsTagStore", () => {
expect(result[0]?.value).toBe("prod");
expect(store.tag.tags(T1)).toEqual(result);
const jsonl = join(dir, "_tags.jsonl");
expect(existsSync(jsonl)).toBe(true);
const content = readFileSync(jsonl, "utf8");
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines).toHaveLength(1);
const parsed = JSON.parse(lines[0] as string) as {
key: string;
value: string;
};
expect(parsed.key).toBe("env");
expect(parsed.value).toBe("prod");
const dbFile = join(dir, "_store.db");
expect(existsSync(dbFile)).toBe(true);
});
test("B2. label tag (no value) records value: null", async () => {
@@ -120,7 +111,7 @@ describe("FsTagStore", () => {
expect(tags.map((t) => t.value)).toEqual(["prod", "platform"]);
});
test("B11. JSONL replay fidelity (set/delete/untag mix)", async () => {
test("B11. SQLite replay fidelity (set/delete/untag mix)", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [{ op: "set", key: "a", value: "1" }]);
store.tag.tag(T1, [{ op: "set", key: "b", value: "2" }]);
+5 -14
View File
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
@@ -11,6 +10,7 @@ import {
TagLabelConflictError,
VariableNotFoundError,
} from "@ocas/core";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { openStore } from "./store.js";
const META_TYPE_KEY = Symbol.for("@ocas/core/bootstrap-store");
@@ -40,7 +40,7 @@ describe("FsVarStore", () => {
rmSync(dir, { recursive: true, force: true });
});
test("A1. set + get round-trip persists to JSONL", async () => {
test("A1. set + get round-trip persists to SQLite", async () => {
const { store, schema, put } = await setupStore(dir);
const h = put("hello");
const v = store.var.set("@app/x", h);
@@ -51,17 +51,8 @@ describe("FsVarStore", () => {
const got = store.var.get("@app/x", schema);
expect(got?.value).toBe(h);
const jsonl = join(dir, "_vars.jsonl");
expect(existsSync(jsonl)).toBe(true);
const content = readFileSync(jsonl, "utf8");
expect(content.length).toBeGreaterThan(0);
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines.length).toBeGreaterThanOrEqual(1);
const matching = lines
.map((l) => JSON.parse(l) as { name?: string; value?: Hash })
.find((r) => r.name === "@app/x");
expect(matching).toBeDefined();
expect(matching?.value).toBe(h);
const dbFile = join(dir, "_store.db");
expect(existsSync(dbFile)).toBe(true);
});
test("A2. name validation", async () => {
-418
View File
@@ -1,418 +0,0 @@
import {
appendFileSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type {
CasStore,
Hash,
ListEntry,
Tag,
TagStore,
Variable,
VarListOptions,
VarStore,
} from "@ocas/core";
import {
addNameIndex,
applyListOptions,
casListEntry,
checkTagLabelConflict,
cloneVarRecord,
extractSchema,
pushHistory,
removeNameIndex,
SchemaMismatchError,
VariableNotFoundError,
type VarRecord,
validateName,
varKey,
} from "@ocas/core";
const VARS_FILE = "_vars.jsonl";
const TAGS_FILE = "_tags.jsonl";
export function createFsVarStoreFor(dir: string, cas: CasStore): VarStore {
const records = new Map<string, VarRecord>();
const byName = new Map<string, Set<string>>();
const path = join(dir, VARS_FILE);
// Load existing records (last record per key wins)
try {
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const rec = JSON.parse(line) as VarRecord & { __op?: string };
if (rec.__op === "remove") {
const k = varKey(rec.name, rec.schema);
records.delete(k);
removeNameIndex(byName, rec.name, k);
} else {
const k = varKey(rec.name, rec.schema);
records.set(k, rec);
addNameIndex(byName, rec.name, k);
}
} catch {
// skip malformed
}
}
} catch {
// file may not exist
}
function persistFull(): void {
mkdirSync(dir, { recursive: true });
const lines: string[] = [];
for (const rec of records.values()) {
lines.push(JSON.stringify(rec));
}
writeFileSync(path, lines.length ? `${lines.join("\n")}\n` : "", "utf8");
}
function appendRecord(rec: VarRecord): void {
mkdirSync(dir, { recursive: true });
appendFileSync(path, `${JSON.stringify(rec)}\n`, "utf8");
}
function appendRemoval(name: string, schema: Hash): void {
mkdirSync(dir, { recursive: true });
appendFileSync(
path,
`${JSON.stringify({ __op: "remove", name, schema })}\n`,
"utf8",
);
}
return {
set(name, hash, options) {
validateName(name);
const schema = extractSchema(cas, hash);
const k = varKey(name, schema);
const existing = records.get(k);
const now = Date.now();
if (existing) {
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const changed = pushHistory(existing, hash, now);
if (changed) {
existing.value = hash;
existing.updated = now;
}
if (options !== undefined) {
existing.tags = { ...tags };
existing.labels = [...labels];
}
persistFull();
return cloneVarRecord(existing);
}
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
checkTagLabelConflict(tags, labels);
const rec: VarRecord = {
name,
schema,
value: hash,
created: now,
updated: now,
tags: { ...tags },
labels: [...labels],
history: [{ value: hash, position: 0, setAt: now }],
};
records.set(k, rec);
addNameIndex(byName, name, k);
appendRecord(rec);
return cloneVarRecord(rec);
},
get(name, schema) {
if (schema !== undefined) {
const rec = records.get(varKey(name, schema));
return rec ? cloneVarRecord(rec) : null;
}
const set = byName.get(name);
if (!set || set.size !== 1) return null;
const onlyKey = set.values().next().value;
if (onlyKey === undefined) return null;
const rec = records.get(onlyKey);
return rec ? cloneVarRecord(rec) : null;
},
remove(name, schema) {
if (schema !== undefined) {
const k = varKey(name, schema);
const rec = records.get(k);
if (!rec) return [];
records.delete(k);
removeNameIndex(byName, name, k);
appendRemoval(name, schema);
return [cloneVarRecord(rec)];
}
const set = byName.get(name);
if (!set) return [];
const removed: Variable[] = [];
for (const k of [...set]) {
const rec = records.get(k);
if (rec) {
removed.push(cloneVarRecord(rec));
records.delete(k);
appendRemoval(rec.name, rec.schema);
}
}
byName.delete(name);
return removed;
},
update(name, hash, options) {
validateName(name);
const newSchema = extractSchema(cas, hash);
const set = byName.get(name);
if (!set || set.size === 0)
throw new VariableNotFoundError(name, newSchema);
const k = varKey(name, newSchema);
const existing = records.get(k);
if (!existing) {
for (const ek of set) {
const erec = records.get(ek);
if (erec) throw new SchemaMismatchError(erec.schema, newSchema);
}
throw new VariableNotFoundError(name, newSchema);
}
const now = Date.now();
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const changed = pushHistory(existing, hash, now);
if (changed) {
existing.value = hash;
existing.updated = now;
}
if (options !== undefined) {
existing.tags = { ...tags };
existing.labels = [...labels];
}
persistFull();
return cloneVarRecord(existing);
},
list(options?: VarListOptions) {
if (
options?.namePrefix !== undefined &&
options?.exactName !== undefined
) {
throw new Error(
"namePrefix and exactName are mutually exclusive - cannot specify both",
);
}
const namePrefix = options?.namePrefix;
const exactName = options?.exactName;
const schema = options?.schema;
const filterTags = options?.tags ?? {};
const filterLabels = options?.labels ?? [];
const sort = options?.sort ?? "created";
const desc = options?.desc ?? false;
const limit = options?.limit;
const offset = options?.offset ?? 0;
if (limit !== undefined && limit <= 0) return [];
let results: VarRecord[] = [];
for (const rec of records.values()) {
if (exactName !== undefined && rec.name !== exactName) continue;
if (namePrefix !== undefined && !rec.name.startsWith(namePrefix))
continue;
if (schema !== undefined && rec.schema !== schema) continue;
let ok = true;
for (const [tk, tv] of Object.entries(filterTags)) {
if (rec.tags[tk] !== tv) {
ok = false;
break;
}
}
if (!ok) continue;
for (const lb of filterLabels) {
if (!rec.labels.includes(lb)) {
ok = false;
break;
}
}
if (!ok) continue;
results.push(rec);
}
results.sort((a, b) => {
const av = sort === "updated" ? a.updated : a.created;
const bv = sort === "updated" ? b.updated : b.created;
if (av !== bv) return desc ? bv - av : av - bv;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});
if (offset > 0) results = results.slice(offset);
if (limit !== undefined) results = results.slice(0, limit);
return results.map(cloneVarRecord);
},
history(name, schema) {
if (schema !== undefined) {
const rec = records.get(varKey(name, schema));
return rec ? rec.history.map((e) => ({ ...e })) : [];
}
const set = byName.get(name);
if (!set || set.size !== 1) return [];
const onlyKey = set.values().next().value;
if (onlyKey === undefined) return [];
const rec = records.get(onlyKey);
return rec ? rec.history.map((e) => ({ ...e })) : [];
},
close() {
// no-op (synchronous file ops)
},
};
}
type StoredTag = {
key: string;
value: string | null;
target: Hash;
created: number;
};
export function createFsTagStore(dir: string): TagStore {
const byTarget = new Map<Hash, Map<string, Tag>>();
const byKey = new Map<string, Set<Hash>>();
const path = join(dir, TAGS_FILE);
function addKeyIndex(k: string, target: Hash): void {
let set = byKey.get(k);
if (!set) {
set = new Set();
byKey.set(k, set);
}
set.add(target);
}
function removeKeyIndex(k: string, target: Hash): void {
const set = byKey.get(k);
if (!set) return;
const tmap = byTarget.get(target);
if (tmap?.has(k)) return;
set.delete(target);
if (set.size === 0) byKey.delete(k);
}
// Load
try {
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const ent = JSON.parse(line) as
| (StoredTag & { __op?: "set" | "untag" })
| { __op: "untag"; target: Hash; key: string };
if ((ent as { __op?: string }).__op === "untag") {
const e = ent as { target: Hash; key: string };
const tm = byTarget.get(e.target);
if (tm) {
tm.delete(e.key);
removeKeyIndex(e.key, e.target);
if (tm.size === 0) byTarget.delete(e.target);
}
} else {
const t = ent as StoredTag;
let tm = byTarget.get(t.target);
if (!tm) {
tm = new Map();
byTarget.set(t.target, tm);
}
tm.set(t.key, {
key: t.key,
value: t.value,
target: t.target,
created: t.created,
});
addKeyIndex(t.key, t.target);
}
} catch {
// skip
}
}
} catch {
// none
}
function append(line: object): void {
mkdirSync(dir, { recursive: true });
appendFileSync(path, `${JSON.stringify(line)}\n`, "utf8");
}
return {
tag(target, ops) {
let tm = byTarget.get(target);
if (!tm) {
tm = new Map();
byTarget.set(target, tm);
}
const now = Date.now();
for (const op of ops) {
if (op.op === "set") {
const existing = tm.get(op.key);
const tag: Tag = {
key: op.key,
value: op.value ?? null,
target,
created: existing?.created ?? now,
};
tm.set(op.key, tag);
addKeyIndex(op.key, target);
append(tag);
} else {
tm.delete(op.key);
removeKeyIndex(op.key, target);
append({ __op: "untag", target, key: op.key });
}
}
return [...tm.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
untag(target, keys) {
const tm = byTarget.get(target);
if (!tm) return;
for (const k of keys) {
tm.delete(k);
removeKeyIndex(k, target);
append({ __op: "untag", target, key: k });
}
if (tm.size === 0) byTarget.delete(target);
},
tags(target) {
const tm = byTarget.get(target);
if (!tm) return [];
return [...tm.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
listByTag(tag, options) {
let key = tag;
let value: string | null | undefined;
const eqIdx = tag.indexOf("=");
if (eqIdx >= 0) {
key = tag.slice(0, eqIdx);
value = tag.slice(eqIdx + 1);
}
const targets = byKey.get(key);
if (!targets) return [];
let entries: ListEntry[] = [];
for (const t of targets) {
const tm = byTarget.get(t);
if (!tm) continue;
const tagEntry = tm.get(key);
if (!tagEntry) continue;
if (value !== undefined && tagEntry.value !== value) continue;
entries.push(casListEntry(t, tagEntry.created));
}
entries = applyListOptions(entries, options);
return entries.map((e) => e.hash);
},
};
}
+2261
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
packages:
- "packages/*"
minimumReleaseAge: 0
allowBuilds:
esbuild: true
sharp: true
workerd: true
+18
View File
@@ -0,0 +1,18 @@
name: "@ocas/workspace"
runtime: node
packages:
- name: "@ocas/core"
path: packages/core
type: lib
- name: "@ocas/fs"
path: packages/fs
type: lib
- name: "@ocas/cli"
path: packages/cli
type: cli
changeset:
fixed: true
release:
registry: https://registry.npmjs.org
access: public
gitTagPrefix: v
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
# Prevent publishing packages that still reference workspace:* dependencies
if grep -q '"workspace:' package.json 2>/dev/null; then
echo "❌ Found workspace:* dependencies in package.json — cannot publish directly."
echo " Use 'changeset publish' which resolves workspace protocol automatically."
grep '"workspace:' package.json
exit 1
fi
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# OCAS Release Preparation
# Creates a release branch, runs changeset version, and validates.
#
# Usage: ./scripts/prepare-release.sh
#
# Prerequisites:
# - Clean working tree on main branch
# - Pending changesets in .changeset/
BOLD='\033[1m'
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
info() { echo -e "${BOLD}${GREEN}${NC} $1"; }
warn() { echo -e "${BOLD}${YELLOW}${NC} $1"; }
error() { echo -e "${BOLD}${RED}${NC} $1"; exit 1; }
# --- Pre-flight checks ---
echo -e "\n${BOLD}OCAS Release Preparation${NC}\n"
# Must be on main
BRANCH=$(git branch --show-current)
[[ "$BRANCH" == "main" ]] || error "Must be on main branch (currently on $BRANCH)"
# Clean working tree
[[ -z "$(git status --porcelain)" ]] || error "Working tree is not clean. Commit or stash changes first."
# Check for pending changesets
CHANGESETS=$(ls .changeset/*.md 2>/dev/null | grep -v README.md || true)
if [[ -z "$CHANGESETS" ]]; then
error "No pending changesets found. Run 'bunx changeset' to add one first."
fi
info "Found pending changesets:"
for cs in $CHANGESETS; do
echo " $(basename "$cs")"
done
# --- Determine version ---
# Dry-run to peek at the version bump
echo ""
info "Previewing version changes..."
bunx changeset status
# --- Create release branch ---
# Get current version to name the branch
CURRENT_VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
info "Current version: $CURRENT_VERSION"
git fetch origin
git checkout -b release/next
# --- Run changeset version ---
info "Running changeset version..."
bunx changeset version
# Show what changed
NEW_VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
info "New version: $NEW_VERSION"
# Rename branch to include actual version
git branch -m "release/next" "release/$NEW_VERSION"
info "Release branch: release/$NEW_VERSION"
# --- Clean up changesets on main ---
info "Removing consumed changesets from main..."
git stash --include-untracked
git checkout main
# Delete the changeset files that were consumed
for cs in $CHANGESETS; do
rm -f "$cs"
done
git add -A
if [[ -n "$(git status --porcelain)" ]]; then
git commit -m "chore: remove changesets consumed by release/$NEW_VERSION"
git push origin main
fi
git checkout "release/$NEW_VERSION"
git stash pop || true
# --- Validate ---
echo ""
info "Running validation..."
echo " → bun install"
bun install --no-cache
echo " → bun run build"
bun run build
echo " → bun run check"
bun run check || warn "Lint warnings found (review above)"
echo " → bun test"
bun test || error "Tests failed!"
info "All checks passed"
# --- Commit ---
git add -A
git commit -m "chore(release): prepare v$NEW_VERSION"
echo ""
info "Release branch ready: release/$NEW_VERSION"
echo ""
echo " Next steps:"
echo " 1. Review changes: git diff main...HEAD"
echo " 2. Fix issues if needed, commit to this branch"
echo " 3. Prerelease: ./scripts/publish.sh --rc"
echo " 4. Final release: ./scripts/publish.sh"
echo ""
-190
View File
@@ -1,190 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# OCAS Publish
# Builds, publishes to npm, tags, and pushes.
#
# Usage: ./scripts/publish.sh # publish final release
# ./scripts/publish.sh --rc # publish prerelease (rc tag)
#
# Prerequisites:
# - On a release/* branch (created by prepare-release.sh)
# - npm authenticated (`npm whoami` works)
# - All checks passing
BOLD='\033[1m'
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
info() { echo -e "${BOLD}${GREEN}${NC} $1"; }
warn() { echo -e "${BOLD}${YELLOW}${NC} $1"; }
error() { echo -e "${BOLD}${RED}${NC} $1"; exit 1; }
# --- Parse flags ---
RC_MODE=false
if [[ "${1:-}" == "--rc" ]]; then
RC_MODE=true
fi
# --- Pre-flight checks ---
echo -e "\n${BOLD}OCAS Publish${NC}\n"
# Must be on release/* branch
BRANCH=$(git branch --show-current)
[[ "$BRANCH" == release/* ]] || error "Must be on a release/* branch (currently on $BRANCH)"
# Clean working tree
[[ -z "$(git status --porcelain)" ]] || error "Working tree is not clean. Commit changes first."
# Extract base version from package.json
BASE_VERSION=$(python3 -c "import json; print(json.load(open('packages/core/package.json'))['version'])")
# Determine publish version and npm tag
if [[ "$RC_MODE" == "true" ]]; then
# Find next rc number
EXISTING_RC=$(npm view "@ocas/core" versions --json 2>/dev/null | python3 -c "
import json, sys, re
versions = json.load(sys.stdin)
if not isinstance(versions, list): versions = [versions]
rc_nums = [int(m.group(1)) for v in versions if (m := re.match(r'^${BASE_VERSION}-rc\.(\d+)$', v))]
print(max(rc_nums) + 1 if rc_nums else 1)
" 2>/dev/null || echo 1)
VERSION="${BASE_VERSION}-rc.${EXISTING_RC}"
NPM_TAG="rc"
info "Prerelease mode: $VERSION (npm tag: rc)"
else
VERSION="$BASE_VERSION"
NPM_TAG="latest"
# No pending changesets (should have been consumed by prepare-release.sh)
CHANGESETS=$(ls .changeset/*.md 2>/dev/null | grep -v README.md || true)
if [[ -n "$CHANGESETS" ]]; then
error "Pending changesets found. Run prepare-release.sh first."
fi
fi
# npm auth check
npm whoami &>/dev/null || error "Not authenticated with npm. Run 'npm login' first."
NPM_USER=$(npm whoami)
info "npm user: $NPM_USER"
# --- Set version in all packages ---
info "Setting version: $VERSION"
for pkg in core fs cli; do
python3 -c "
import json
path = 'packages/$pkg/package.json'
data = json.load(open(path))
data['version'] = '$VERSION'
# Fix workspace:* to actual version for publishing
for dep in list(data.get('dependencies', {})):
if data['dependencies'][dep] == 'workspace:*':
data['dependencies'][dep] = '$VERSION'
json.dump(data, open(path, 'w'), indent=2)
open(path, 'a').write('\n')
"
done
# --- Final validation ---
info "Running final validation..."
echo " → bun run build"
bun run build
echo " → bun test"
bun test || error "Tests failed! Fix before publishing."
# --- Publish (order matters: core → fs → cli) ---
echo ""
echo -e "${BOLD}Will publish:${NC}"
for pkg in core fs cli; do
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
echo " $PKG_NAME@$VERSION (tag: $NPM_TAG)"
done
for pkg in core fs cli; do
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
echo ""
info "Publishing $PKG_NAME@$VERSION..."
(cd "packages/$pkg" && bun publish --access public --tag "$NPM_TAG")
info "$PKG_NAME@$VERSION published ✓"
done
# --- Commit version changes ---
git add -A
if [[ -n "$(git diff --cached --name-only)" ]]; then
git commit -m "chore(release): set version $VERSION"
fi
# --- For final release: tag, push, merge back ---
if [[ "$RC_MODE" == "false" ]]; then
TAG="v$VERSION"
git tag -a "$TAG" -m "Release $TAG"
git push origin "$BRANCH" --tags
git push github "$BRANCH" --tags 2>/dev/null || true
info "Tag $TAG pushed"
# Merge back to main
echo ""
info "Merging release into main..."
git checkout main
git merge "$BRANCH" --no-ff -m "chore: merge release $TAG"
# Restore workspace:* on main
for pkg in fs cli; do
python3 -c "
import json
path = 'packages/$pkg/package.json'
data = json.load(open(path))
for dep in list(data.get('dependencies', {})):
if dep.startswith('@ocas/'):
data['dependencies'][dep] = 'workspace:*'
json.dump(data, open(path, 'w'), indent=2)
open(path, 'a').write('\n')
"
done
git add -A
git commit -m "chore: restore workspace:* deps on main" || true
git push origin main
git push github main 2>/dev/null || true
info "Merged to main"
# Clean up release branch
git branch -d "$BRANCH"
git push origin --delete "$BRANCH" 2>/dev/null || true
git push github --delete "$BRANCH" 2>/dev/null || true
info "Release branch cleaned up"
echo ""
info "Release $TAG complete! 🎉"
echo ""
echo " Published:"
for pkg in core fs cli; do
PKG_NAME=$(python3 -c "import json; print(json.load(open('packages/$pkg/package.json'))['name'])")
echo " https://www.npmjs.com/package/$PKG_NAME"
done
else
# RC: just push the branch
git push origin "$BRANCH"
git push github "$BRANCH" 2>/dev/null || true
echo ""
info "Prerelease $VERSION published! 🏷️"
echo ""
echo " Install for testing:"
echo " bun add -g @ocas/cli@rc"
echo ""
echo " When verified, run final release:"
echo " ./scripts/publish.sh"
fi
echo ""
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"types": ["bun-types"],
"types": ["node"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["packages/*/src/**/*.test.ts", "packages/*/tests/**/*.test.ts"],
testTimeout: 30000,
},
});