From 4ba3a00de93c3ec3688851e494f4d44bdc617fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 7 Jun 2026 01:13:36 +0000 Subject: [PATCH 1/2] 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 ` 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 --- .changeset/export-import-bundle.md | 9 + packages/cli/src/index.ts | 115 ++++- .../__snapshots__/edge-cases.test.ts.snap | 19 + packages/cli/tests/export-import.test.ts | 263 +++++++++++ packages/core/src/bootstrap.test.ts | 8 +- packages/core/src/bootstrap.ts | 36 ++ packages/core/src/bundle.test.ts | 423 ++++++++++++++++++ packages/core/src/bundle.ts | 398 ++++++++++++++++ packages/core/src/closure.test.ts | 205 +++++++++ packages/core/src/closure.ts | 117 +++++ packages/core/src/index.test.ts | 8 +- packages/core/src/index.ts | 9 + packages/fs/src/store.test.ts | 2 +- 13 files changed, 1603 insertions(+), 9 deletions(-) create mode 100644 .changeset/export-import-bundle.md create mode 100644 packages/cli/tests/export-import.test.ts create mode 100644 packages/core/src/bundle.test.ts create mode 100644 packages/core/src/bundle.ts create mode 100644 packages/core/src/closure.test.ts create mode 100644 packages/core/src/closure.ts diff --git a/.changeset/export-import-bundle.md b/.changeset/export-import-bundle.md new file mode 100644 index 0000000..22ebf99 --- /dev/null +++ b/.changeset/export-import-bundle.md @@ -0,0 +1,9 @@ +--- +"@ocas/core": minor +"@ocas/cli": minor +--- + +Add CAS closure export/import (`ocas export` / `ocas import`): + +- **`@ocas/core`**: 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`). Added `@ocas/output/export` and `@ocas/output/import` builtin output schemas. +- **`@ocas/cli`**: New `ocas export [ ...] -o ` and `ocas import [--scope @new]` commands. New global `--store ` flag opens a bundle as a read-only store for inspection commands (`get`, `walk`, `refs`, `var list`, …). Write commands reject `--store` with a clear error. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 874c395..9a8511e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -14,9 +14,12 @@ import { applyListOptions, CasNodeNotFoundError, computeHash, + exportBundle, gc, getSchema, InvalidVariableNameError, + importBundle, + loadBundleStore, putSchema, refs, renderAsync, @@ -48,6 +51,9 @@ const VALUE_FLAGS = new Set([ "sort", "limit", "offset", + "store", + "scope", + "o", ]); function parseArgs(argv: string[]): { flags: Flags; positional: string[] } { @@ -85,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); } @@ -161,15 +175,48 @@ async function readStdinJson(): Promise { } } +/** + * 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 ` is passed, returns a read-only + * bundle-backed Store instead. */ async function openStore(): Promise { + 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. */ @@ -991,6 +1038,51 @@ async function cmdGc(_args: string[]): Promise { await out(await wrapEnvelope(store, "@ocas/output/gc", stats), store); } +async function cmdExport(args: string[]): Promise { + if (args.length === 0) { + die( + "Usage: ocas export ... -o \n ocas export ... -o ", + ); + } + const output = flags.o; + if (typeof output !== "string") { + die( + "Error: -o is required.\nUsage: ocas export ... -o ", + ); + } + + 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 { + const bundlePath = args[0]; + if (!bundlePath) { + die("Usage: ocas import [--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 { const typeFlag = flags.type; if (typeof typeFlag !== "string") @@ -1104,9 +1196,12 @@ Commands: template list List all templates (@ocas/output/template-list) template delete Delete template for schema (@ocas/output/template-delete) gc Run garbage collection (@ocas/output/gc) + export ... -o Export CAS closure of roots to a tar bundle + import [--scope @s] Import nodes/vars/tags from a bundle into the store Flags: --home Store directory (default: $OCAS_HOME or ~/.ocas) + --store 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 Schema hash filter for var get/delete/tag/list @@ -1116,6 +1211,8 @@ Flags: --decay Decay factor for render (default: 0.5) --epsilon Cutoff threshold for render (default: 0.01) --pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope) + --scope Variable name remap target for import (e.g. --scope @imported) + -o Output path for export Agent: If you have not installed the ocas skill for this version (${pkg.version}), run \`ocas prompt bootstrap\` and follow the instructions.`); } @@ -1129,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); @@ -1231,6 +1336,14 @@ 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) { diff --git a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap index c21afe8..6a80cf1 100644 --- a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap +++ b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap @@ -86,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", @@ -114,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", @@ -453,9 +467,12 @@ Commands: template list List all templates (@ocas/output/template-list) template delete Delete template for schema (@ocas/output/template-delete) gc Run garbage collection (@ocas/output/gc) + export ... -o Export CAS closure of roots to a tar bundle + import [--scope @s] Import nodes/vars/tags from a bundle into the store Flags: --home Store directory (default: $OCAS_HOME or ~/.ocas) + --store 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 Schema hash filter for var get/delete/tag/list @@ -465,6 +482,8 @@ Flags: --decay Decay factor for render (default: 0.5) --epsilon Cutoff threshold for render (default: 0.01) --pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope) + --scope Variable name remap target for import (e.g. --scope @imported) + -o Output path for export Agent: If you have not installed the ocas skill for this version (0.3.1), run \`ocas prompt bootstrap\` and follow the instructions." `; diff --git a/packages/cli/tests/export-import.test.ts b/packages/cli/tests/export-import.test.ts new file mode 100644 index 0000000..d0a6e7c --- /dev/null +++ b/packages/cli/tests/export-import.test.ts @@ -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; diff --git a/packages/core/src/bootstrap.test.ts b/packages/core/src/bootstrap.test.ts index 11a9560..7cd1f50 100644 --- a/packages/core/src/bootstrap.test.ts +++ b/packages/core/src/bootstrap.test.ts @@ -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)) { diff --git a/packages/core/src/bootstrap.ts b/packages/core/src/bootstrap.ts index c48b235..0e893d9 100644 --- a/packages/core/src/bootstrap.ts +++ b/packages/core/src/bootstrap.ts @@ -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", + }, + ], ]; /** diff --git a/packages/core/src/bundle.test.ts b/packages/core/src/bundle.test.ts new file mode 100644 index 0000000..dce9eb6 --- /dev/null +++ b/packages/core/src/bundle.test.ts @@ -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; diff --git a/packages/core/src/bundle.ts b/packages/core/src/bundle.ts new file mode 100644 index 0000000..950fd91 --- /dev/null +++ b/packages/core/src/bundle.ts @@ -0,0 +1,398 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { bootstrap } from "./bootstrap.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. */ +import { decode } from "cborg"; + +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/.bin`), variables + * (`vars.jsonl`), and tags (`tags.jsonl`). + */ +export async function exportBundle( + store: Store, + roots: string[], + outputPath: string, +): Promise { + // 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; + vars: Variable[]; + tags: Tag[]; +} { + const buf = readFileSync(bundlePath); + const entries = unpackTar(buf); + const nodes = new Map(); + 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 { + // 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 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]; + if (fn) { + fn(node.payload); + } else { + target.cas.put(node.type, node.payload); + } + void bootstrapSym; + 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 { + 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"); +} diff --git a/packages/core/src/closure.test.ts b/packages/core/src/closure.test.ts new file mode 100644 index 0000000..8d0b87d --- /dev/null +++ b/packages/core/src/closure.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/closure.ts b/packages/core/src/closure.ts new file mode 100644 index 0000000..4696e1b --- /dev/null +++ b/packages/core/src/closure.ts @@ -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; + /** 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; +}; + +/** + * 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(); + + // 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(); + 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/` 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(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(); + for (const hash of nodes) { + const tagList = store.tag.tags(hash); + if (tagList.length > 0) { + tags.set(hash, tagList); + } + } + + return { nodes, vars, tags }; +} diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 340c452..a03bb68 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -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); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e5b9693..8356945 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/fs/src/store.test.ts b/packages/fs/src/store.test.ts index 5d00bad..c3aa7ab 100644 --- a/packages/fs/src/store.test.ts +++ b/packages/fs/src/store.test.ts @@ -69,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); }); }); -- 2.43.0 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 2/2] 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); -- 2.43.0