From dbfaf010310441d29610ed91bbee64139df4ce56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 7 Jun 2026 03:19:59 +0000 Subject: [PATCH] docs: add export/import to README/cards; tidy bundle.ts imports 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) --- .cards/cli.md | 26 +++++++++++++++++++++ README.md | 26 +++++++++++++++++++++ packages/cli/README.md | 32 +++++++++++++++++++++++++ packages/cli/prompts/usage.md | 22 ++++++++++++++++++ packages/core/README.md | 44 +++++++++++++++++++++++++++++++++++ packages/core/src/bundle.ts | 10 +++----- 6 files changed, 153 insertions(+), 7 deletions(-) diff --git a/.cards/cli.md b/.cards/cli.md index 1ef8c91..d023a46 100644 --- a/.cards/cli.md +++ b/.cards/cli.md @@ -68,12 +68,36 @@ ocas render [--resolution n] [--decay n] [--epsilon n] ocas render --pipe/-p [options] ``` +### Bundle Export / Import + +```bash +ocas export ... -o # write CAS closure of roots to tar +ocas import [--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/.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 ` 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 ` | Store directory | | `--var-db ` | Variable database path | +| `--store ` | 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 ` | Schema filter for var commands | @@ -85,6 +109,8 @@ ocas render --pipe/-p [options] | `--limit ` | Max results to return (default: 100) | | `--offset ` | Skip first N results (default: 0) | | `--desc` | Sort descending (default: ascending) | +| `-o ` | Output file path (used by `export`) | +| `--scope @new` | Variable scope remap on import (used by `import`) | ## Variable Names diff --git a/README.md b/README.md index 9bc5b91..9c41808 100644 --- a/README.md +++ b/README.md @@ -159,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 ` 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 ` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) | | `--var-db ` | Variable database path | +| `--store ` | 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 | diff --git a/packages/cli/README.md b/packages/cli/README.md index 7676e2b..5e51701 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -40,6 +40,7 @@ Usage: ocas [--home ] [--json] [args] |------|-------------| | `--home ` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) | | `--var-db ` | Variable database path (default: `/variables.db`) | +| `--store ` | 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 ` | `{ deleted: boolean }` | `@output/template-delete` | | `gc` | `{ total, reachable, collected, scanned }` | `@output/gc` | +| `export -o ` | `{ nodes, vars, tags }` | `@output/export` | +| `import [--scope @new]` | nested `{ nodes, vars, tags }` stats | `@output/import` | ### Examples @@ -143,6 +146,35 @@ ocas render # → 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/.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 ` (required for `export`) names the output tar. `--scope @new` rewrites the +leading `@scope` of every imported variable name except builtins (`@ocas/*`). +`--store ` 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 | diff --git a/packages/cli/prompts/usage.md b/packages/cli/prompts/usage.md index a28715c..7591ef6 100644 --- a/packages/cli/prompts/usage.md +++ b/packages/cli/prompts/usage.md @@ -133,11 +133,31 @@ ocas gc # collect unreachable nodes ocas gc | ocas render -p # human-readable stats ``` +### Bundles (Export / Import) + +```bash +ocas export ... -o # 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 # import bundle (idempotent) +ocas import --scope @prod # remap @/* → @prod/* +ocas get --store # read-only access into bundle +``` + +`export` walks refs **and** schema chains; the resulting tar contains every reachable +CAS node (`cas/.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 ` 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 ` | Store directory (default: `$OCAS_HOME` or `~/.ocas`) | +| `--store ` | 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 | @@ -145,6 +165,8 @@ ocas gc | ocas render -p # human-readable stats | `--limit ` | Max results (default: 100) | | `--offset ` | Skip first N (default: 0) | | `--desc` | Sort descending | +| `-o ` | Output path (used by `export`) | +| `--scope @new` | Variable scope remap (used by `import`) | ## Pipe Composition Patterns diff --git a/packages/core/README.md b/packages/core/README.md index 1e8fac5..b117a66 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -99,6 +99,48 @@ async function verify(hash: Hash, node: CasNode): Promise; Recomputes hash from `node` and compares to `hash` (self-referencing vs normal rules). +### Closure & Bundles + +```typescript +type ClosureResult = { + nodes: Set; + vars: Variable[]; + tags: Map; +}; +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; +async function importBundle( + bundlePath: string, + target: Store, + options?: ImportOptions, +): Promise; +async function loadBundleStore(bundlePath: string): Promise; +``` + +- `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/.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 @@ -149,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/`. diff --git a/packages/core/src/bundle.ts b/packages/core/src/bundle.ts index 950fd91..b2f9b4f 100644 --- a/packages/core/src/bundle.ts +++ b/packages/core/src/bundle.ts @@ -1,5 +1,7 @@ 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"; @@ -33,7 +35,6 @@ export type ImportStats = { }; /** Import via CBOR using cborg, mirroring how FsStore decodes nodes. */ -import { decode } from "cborg"; const BUILTIN_PREFIX = "@ocas/"; @@ -231,17 +232,12 @@ export async function importBundle( const cas = target.cas as unknown as { [k: symbol]: ((p: unknown) => Hash) | undefined; }; - const bootstrapSym = Symbol.for("ocas.bootstrap-store"); - // Look up the proper symbol from the module to avoid forging it. - // (Imported lazily to avoid circular dependency at module init.) - const sym = (await import("./bootstrap-capable.js")).BOOTSTRAP_STORE; - const fn = cas[sym]; + const fn = cas[BOOTSTRAP_STORE]; if (fn) { fn(node.payload); } else { target.cas.put(node.type, node.payload); } - void bootstrapSym; imported++; } else { target.cas.put(node.type, node.payload);