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)
This commit is contained in:
2026-06-07 03:19:59 +00:00
parent 4ba3a00de9
commit dbfaf01031
6 changed files with 153 additions and 7 deletions
+26
View File
@@ -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
+26
View File
@@ -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 <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 |
+32
View File
@@ -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 |
+22
View File
@@ -133,11 +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`) |
| `--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 |
@@ -145,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
+44
View File
@@ -99,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
@@ -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/`.
+3 -7
View File
@@ -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);