feat: add CAS closure export/import bundles

Implements `ocas export` / `ocas import` for shipping a self-contained
closure of CAS nodes, variables and tags between stores, plus a
read-only `--store <bundle.tar>` flag for inspecting bundles without
extracting them.

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

Closes #83
This commit is contained in:
2026-06-07 01:13:36 +00:00
parent dd5cb49168
commit 4ba3a00de9
13 changed files with 1603 additions and 9 deletions
+114 -1
View File
@@ -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<unknown> {
}
}
/**
* Set of write-mutating commands that cannot run against a bundle (read-only).
* Subcommands are also recorded as `cmd:sub`.
*/
const WRITE_COMMANDS = new Set([
"put",
"tag",
"untag",
"gc",
"import",
"var:set",
"var:delete",
"template:set",
"template:delete",
]);
/**
* Open the filesystem-backed Store. Automatically creates directory and
* bootstraps if needed.
* bootstraps if needed. If `--store <bundle>` is passed, returns a read-only
* bundle-backed Store instead.
*/
async function openStore(): Promise<Store> {
if (typeof flags.store === "string") {
return await loadBundleStore(flags.store);
}
const fullPath = resolve(storePath);
return await openFsStore(fullPath);
}
/**
* Reject write commands when --store points at a bundle. Should be called
* from the dispatch layer before any write command runs.
*/
function ensureWritable(commandKey: string): void {
if (typeof flags.store !== "string") return;
if (WRITE_COMMANDS.has(commandKey)) {
die(
`Error: --store is read-only — '${commandKey}' is not allowed against a bundle. Use --home for a writable store.`,
);
}
}
/**
* Hash format check: 13-char uppercase Crockford Base32.
*/
@@ -991,6 +1038,51 @@ async function cmdGc(_args: string[]): Promise<void> {
await out(await wrapEnvelope(store, "@ocas/output/gc", stats), store);
}
async function cmdExport(args: string[]): Promise<void> {
if (args.length === 0) {
die(
"Usage: ocas export <root>... -o <bundle.tar>\n ocas export <hash>... -o <bundle.tar>",
);
}
const output = flags.o;
if (typeof output !== "string") {
die(
"Error: -o <output-path> is required.\nUsage: ocas export <root>... -o <bundle.tar>",
);
}
const store = await openStore();
try {
const stats = await exportBundle(store, args, output);
await out(await wrapEnvelope(store, "@ocas/output/export", stats), store);
} catch (e) {
if (e instanceof Error) {
die(`Error: ${e.message}`);
}
throw e;
}
}
async function cmdImport(args: string[]): Promise<void> {
const bundlePath = args[0];
if (!bundlePath) {
die("Usage: ocas import <bundle.tar> [--scope @newscope]");
}
const scope = typeof flags.scope === "string" ? flags.scope : undefined;
const store = await openStore();
try {
const opts = scope !== undefined ? { scope } : undefined;
const stats = await importBundle(bundlePath, store, opts);
await out(await wrapEnvelope(store, "@ocas/output/import", stats), store);
} catch (e) {
if (e instanceof Error) {
die(`Error: ${e.message}`);
}
throw e;
}
}
async function cmdList(_args: string[]): Promise<void> {
const typeFlag = flags.type;
if (typeof typeFlag !== "string")
@@ -1104,9 +1196,12 @@ Commands:
template list List all templates (@ocas/output/template-list)
template delete <schema-hash> Delete template for schema (@ocas/output/template-delete)
gc Run garbage collection (@ocas/output/gc)
export <root>... -o <file> Export CAS closure of roots to a tar bundle
import <bundle> [--scope @s] Import nodes/vars/tags from a bundle into the store
Flags:
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
--store <bundle> Use a tar bundle as a read-only store (instead of --home)
--json Compact JSON output
--render, -r Render output inline (equivalent to | ocas render -p)
--schema <hash> Schema hash filter for var get/delete/tag/list
@@ -1116,6 +1211,8 @@ Flags:
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)
--scope <name> Variable name remap target for import (e.g. --scope @imported)
-o <file> Output path for export
Agent: If you have not installed the ocas skill for this version (${pkg.version}), run \`ocas prompt 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) {
@@ -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 <schema-hash> Delete template for schema (@ocas/output/template-delete)
gc Run garbage collection (@ocas/output/gc)
export <root>... -o <file> Export CAS closure of roots to a tar bundle
import <bundle> [--scope @s] Import nodes/vars/tags from a bundle into the store
Flags:
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
--store <bundle> Use a tar bundle as a read-only store (instead of --home)
--json Compact JSON output
--render, -r Render output inline (equivalent to | ocas render -p)
--schema <hash> Schema hash filter for var get/delete/tag/list
@@ -465,6 +482,8 @@ Flags:
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)
--scope <name> Variable name remap target for import (e.g. --scope @imported)
-o <file> Output path for export
Agent: If you have not installed the ocas skill for this version (0.3.1), run \`ocas prompt bootstrap\` and follow the instructions."
`;
+263
View File
@@ -0,0 +1,263 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { envValue, runCli } from "./helpers";
let storePath: string;
let bundlePath: string;
beforeEach(() => {
storePath = mkdtempSync(join(tmpdir(), "ocas-export-import-"));
bundlePath = join(storePath, "bundle.tar");
});
afterEach(() => {
rmSync(storePath, { recursive: true, force: true });
});
async function setupSampleStore(): Promise<{
schemaHash: string;
nodeHash: string;
}> {
// Create a schema, a node, a variable, and a tag.
const { openStore } = await import("@ocas/fs");
const { putSchema } = await import("@ocas/core");
const store = await openStore(storePath);
const schemaHash = putSchema(store, {
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
});
const nodeHash = store.cas.put(schemaHash, { name: "Alice", age: 30 });
store.var.set("@test/app", nodeHash);
store.tag.tag(nodeHash, [{ op: "set", key: "env", value: "prod" }]);
return { schemaHash, nodeHash };
}
describe("CLI export/import", () => {
test("3.1 export: basic usage with -o flag", async () => {
const { nodeHash } = await setupSampleStore();
const { exitCode, stdout } = runCli(
["export", "@test/app", "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(0);
expect(existsSync(bundlePath)).toBe(true);
const value = envValue(stdout) as {
nodes: number;
vars: number;
tags: number;
};
expect(value.nodes).toBeGreaterThan(0);
expect(value.vars).toBeGreaterThanOrEqual(1);
expect(value.tags).toBeGreaterThanOrEqual(1);
void nodeHash;
});
test("3.2 export: multiple roots", async () => {
const { openStore } = await import("@ocas/fs");
const { putSchema } = await import("@ocas/core");
const store = await openStore(storePath);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "a");
const bHash = store.cas.put(schemaHash, "b");
store.var.set("@test/a", aHash);
store.var.set("@test/b", bHash);
const { exitCode } = runCli(
["export", "@test/a", "@test/b", "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(0);
expect(existsSync(bundlePath)).toBe(true);
});
test("3.3 export: hash as root", async () => {
const { nodeHash } = await setupSampleStore();
const { exitCode } = runCli(
["export", nodeHash, "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(0);
});
test("3.4 export: missing root → error", async () => {
await setupSampleStore();
const { exitCode, stderr } = runCli(
["export", "@test/nonexistent", "-o", bundlePath],
storePath,
);
expect(exitCode).toBe(1);
expect(stderr.length).toBeGreaterThan(0);
});
test("3.5 export: missing -o flag → error", async () => {
await setupSampleStore();
const { exitCode, stderr } = runCli(["export", "@test/app"], storePath);
expect(exitCode).toBe(1);
expect(stderr).toMatch(/-o|output/i);
});
test("3.6 import: basic", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-dst-"));
try {
const { exitCode, stdout } = runCli(["import", bundlePath], dstPath);
expect(exitCode).toBe(0);
const stats = envValue(stdout) as {
nodes: { imported: number; skipped: number };
vars: { created: number; updated: number };
tags: number;
};
expect(stats.nodes.imported).toBeGreaterThan(0);
// Variable accessible in dst.
const get = runCli(["get", "@test/app"], dstPath);
expect(get.exitCode).toBe(0);
} finally {
rmSync(dstPath, { recursive: true, force: true });
}
});
test("3.7 import --scope remaps variables", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-scope-"));
try {
const { exitCode } = runCli(
["import", bundlePath, "--scope", "@imported"],
dstPath,
);
expect(exitCode).toBe(0);
const list = runCli(["var", "list", "@imported"], dstPath);
expect(list.exitCode).toBe(0);
const variables = envValue(list.stdout) as Array<{ name: string }>;
expect(variables.some((v) => v.name === "@imported/app")).toBe(true);
} finally {
rmSync(dstPath, { recursive: true, force: true });
}
});
test("3.8 import is idempotent", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const dstPath = mkdtempSync(join(tmpdir(), "ocas-import-idem-"));
try {
runCli(["import", bundlePath], dstPath);
const second = runCli(["import", bundlePath], dstPath);
expect(second.exitCode).toBe(0);
const stats = envValue(second.stdout) as {
nodes: { imported: number; skipped: number };
};
expect(stats.nodes.imported).toBe(0);
expect(stats.nodes.skipped).toBeGreaterThan(0);
} finally {
rmSync(dstPath, { recursive: true, force: true });
}
});
test("3.9 --store flag: ocas get reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode, stdout } = runCli([
"get",
nodeHash,
"--store",
bundlePath,
]);
expect(exitCode).toBe(0);
const value = envValue(stdout) as { payload: { name: string } };
expect(value.payload.name).toBe("Alice");
});
test("3.10 --store flag: ocas var list reads from bundle", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode, stdout } = runCli([
"var",
"list",
"@test",
"--store",
bundlePath,
]);
expect(exitCode).toBe(0);
const variables = envValue(stdout) as Array<{ name: string }>;
expect(variables.some((v) => v.name === "@test/app")).toBe(true);
});
test("3.11 --store flag: ocas walk reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode, stdout } = runCli([
"walk",
nodeHash,
"--store",
bundlePath,
]);
expect(exitCode).toBe(0);
const hashes = envValue(stdout) as string[];
expect(hashes).toContain(nodeHash);
});
test("3.12 --store flag: ocas refs reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const { exitCode } = runCli(["refs", nodeHash, "--store", bundlePath]);
expect(exitCode).toBe(0);
});
test("3.13 --store flag: ocas has reads from bundle", async () => {
const { nodeHash } = await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
const present = runCli(["has", nodeHash, "--store", bundlePath]);
expect(present.exitCode).toBe(0);
expect(envValue(present.stdout)).toBe(true);
const missing = runCli(["has", "AAAAAAAAAAAAA", "--store", bundlePath]);
expect(missing.exitCode).toBe(0);
expect(envValue(missing.stdout)).toBe(false);
});
test("3.14 --store flag: write commands fail with 'read-only' error", async () => {
await setupSampleStore();
runCli(["export", "@test/app", "-o", bundlePath], storePath);
// Try `ocas put` against a bundle.
const tmp = mkdtempSync(join(tmpdir(), "ocas-store-write-"));
try {
const payload = join(tmp, "p.json");
writeFileSync(payload, JSON.stringify({ name: "X" }));
const { exitCode, stderr } = runCli([
"put",
"@ocas/string",
payload,
"--store",
bundlePath,
]);
expect(exitCode).toBe(1);
expect(stderr).toMatch(/read[- ]only/i);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
// Suppress unused.
void mkdirSync;
+5 -3
View File
@@ -27,6 +27,8 @@ const OUTPUT_ALIASES = [
"@ocas/output/template-list",
"@ocas/output/template-delete",
"@ocas/output/gc",
"@ocas/output/export",
"@ocas/output/import",
] as const;
// ──────────────────────────────────────────────────────────────────────────────
@@ -34,11 +36,11 @@ const OUTPUT_ALIASES = [
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of 31 built-in schema aliases to hashes", async () => {
test("should return map of 33 built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = bootstrap(store);
// Should return object with 9 primitive + 22 output aliases = 31
// Should return object with 9 primitive + 24 output aliases = 33
expect(builtinSchemas).toHaveProperty("@ocas/schema");
expect(builtinSchemas).toHaveProperty("@ocas/string");
expect(builtinSchemas).toHaveProperty("@ocas/number");
@@ -53,7 +55,7 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(31);
expect(Object.keys(builtinSchemas)).toHaveLength(33);
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
+36
View File
@@ -367,6 +367,42 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
title: "ocas gc result",
},
],
[
"@ocas/output/export",
{
type: "object",
properties: {
nodes: { type: "number" },
vars: { type: "number" },
tags: { type: "number" },
},
title: "ocas export result",
},
],
[
"@ocas/output/import",
{
type: "object",
properties: {
nodes: {
type: "object",
properties: {
imported: { type: "number" },
skipped: { type: "number" },
},
},
vars: {
type: "object",
properties: {
created: { type: "number" },
updated: { type: "number" },
},
},
tags: { type: "number" },
},
title: "ocas import result",
},
],
];
/**
+423
View File
@@ -0,0 +1,423 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { exportBundle, importBundle, loadBundleStore } from "./bundle.js";
import { cborEncode } from "./cbor.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "ocas-bundle-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("exportBundle / importBundle / loadBundleStore", () => {
test("2.1 export: tar file structure includes cas/, vars.jsonl, tags.jsonl", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, {
type: "object",
properties: { x: { type: "number" } },
});
const aHash = store.cas.put(schemaHash, { x: 42 });
store.var.set("@test/config", aHash);
store.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
const stats = await exportBundle(store, ["@test/config"], out);
const buf = readFileSync(out);
// Standard tar should have 512-byte aligned blocks.
expect(buf.length % 512).toBe(0);
// Parse out the entry names from the tar.
const names = listTarEntries(buf);
expect(names.some((n) => n === `cas/${aHash}.bin`)).toBe(true);
expect(names.some((n) => n === `cas/${schemaHash}.bin`)).toBe(true);
expect(names).toContain("vars.jsonl");
expect(names).toContain("tags.jsonl");
expect(stats.nodes).toBeGreaterThanOrEqual(2);
expect(stats.vars).toBeGreaterThanOrEqual(1);
expect(stats.tags).toBeGreaterThanOrEqual(1);
});
test("2.2 export: CAS node binary identity is preserved", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "hello");
store.var.set("@test/h", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(store, ["@test/h"], out);
const buf = readFileSync(out);
const entries = readTarEntries(buf);
const casEntry = entries.find((e) => e.name === `cas/${aHash}.bin`);
expect(casEntry).toBeDefined();
const node = store.cas.get(aHash);
expect(node).not.toBeNull();
if (!node) return;
const expected = cborEncode({
type: node.type,
payload: node.payload,
timestamp: node.timestamp,
});
expect(casEntry?.content).toEqual(expected);
});
test("2.3 export: vars.jsonl contains parseable JSON lines", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "a");
const bHash = store.cas.put(schemaHash, "b");
store.var.set("@test/a", aHash);
store.var.set("@test/b", bHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(store, ["@test/a", "@test/b"], out);
const entries = readTarEntries(readFileSync(out));
const vars = entries.find((e) => e.name === "vars.jsonl");
expect(vars).toBeDefined();
const text = new TextDecoder().decode(vars?.content);
const lines = text.split("\n").filter((l) => l.length > 0);
const records = lines.map(
(l) => JSON.parse(l) as { name: string; value: string },
);
const names = records.map((r) => r.name);
expect(names).toContain("@test/a");
expect(names).toContain("@test/b");
const aRec = records.find((r) => r.name === "@test/a");
expect(aRec?.value).toBe(aHash);
});
test("2.4 export: tags.jsonl contains target/key/value records", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "tagged");
store.var.set("@test/t", aHash);
store.tag.tag(aHash, [
{ op: "set", key: "env", value: "prod" },
{ op: "set", key: "stable" },
]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(store, ["@test/t"], out);
const entries = readTarEntries(readFileSync(out));
const tagEntry = entries.find((e) => e.name === "tags.jsonl");
expect(tagEntry).toBeDefined();
const text = new TextDecoder().decode(tagEntry?.content);
const lines = text.split("\n").filter((l) => l.length > 0);
const records = lines.map(
(l) =>
JSON.parse(l) as {
target: string;
key: string;
value: string | null;
},
);
const env = records.find((r) => r.key === "env");
expect(env?.value).toBe("prod");
expect(env?.target).toBe(aHash);
const stable = records.find((r) => r.key === "stable");
expect(stable?.value).toBeNull();
});
test("2.5 export: accepts variable names and raw hashes as roots", async () => {
const store = createMemoryStore();
bootstrap(store);
const schemaHash = putSchema(store, { type: "string" });
const aHash = store.cas.put(schemaHash, "x");
store.var.set("@test/c", aHash);
const out1 = join(tmpDir, "by-name.tar");
const out2 = join(tmpDir, "by-hash.tar");
await exportBundle(store, ["@test/c"], out1);
await exportBundle(store, [aHash], out2);
const names1 = listTarEntries(readFileSync(out1));
const names2 = listTarEntries(readFileSync(out2));
expect(names1).toContain(`cas/${aHash}.bin`);
expect(names2).toContain(`cas/${aHash}.bin`);
});
test("2.6 export: non-existent root throws", async () => {
const store = createMemoryStore();
bootstrap(store);
const out = join(tmpDir, "bundle.tar");
await expect(
exportBundle(store, ["@test/nonexistent"], out),
).rejects.toThrow();
});
test("2.7 import: nodes are written to target store", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, {
type: "object",
properties: { x: { type: "number" } },
});
const aHash = src.cas.put(schemaHash, { x: 1 });
src.var.set("@test/c", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst);
expect(dst.cas.has(aHash)).toBe(true);
const node = dst.cas.get(aHash);
expect(node?.type).toBe(schemaHash);
expect(node?.payload).toEqual({ x: 1 });
});
test("2.8 import: skip existing nodes (content-addressed dedup)", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "a");
src.var.set("@test/c", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
// Pre-populate destination with the same node
dst.cas.put(schemaHash, "a"); // wait — schemaHash may not exist in dst
// To deduplicate, we need to ensure the same hash is computed.
// Re-import the schema first via import.
const stats = await importBundle(out, dst);
// After two imports the second's nodes.skipped should equal nodes.imported of the first.
const stats2 = await importBundle(out, dst);
expect(stats2.nodes.skipped).toBeGreaterThan(0);
expect(stats2.nodes.imported).toBe(0);
void stats;
});
test("2.9 import: variables created without scope use original names", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst);
const v = dst.var.get("@test/config");
expect(v?.value).toBe(aHash);
});
test("2.10 import: scope remapping rewrites variable names", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst, { scope: "@imported" });
const remapped = dst.var.get("@imported/config");
expect(remapped?.value).toBe(aHash);
const original = dst.var.get("@test/config");
expect(original).toBeNull();
});
test("2.11 import: @ocas/* builtin variables are NOT remapped", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst, { scope: "@imported" });
// @ocas/schema, @ocas/string etc. should still be reachable as-is.
expect(dst.var.get("@ocas/schema")).not.toBeNull();
// No variant under the remapped scope.
expect(dst.var.get("@imported/schema")).toBeNull();
});
test("2.12 import: variable conflict — overwrite with stats marking 'updated'", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "imported");
src.var.set("@test/config", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const dst = createMemoryStore();
bootstrap(dst);
// Pre-populate destination with same name → different value.
const dstSchema = putSchema(dst, { type: "string" });
const bHash = dst.cas.put(dstSchema, "preexisting");
dst.var.set("@test/config", bHash);
const stats = await importBundle(out, dst);
expect(stats.vars.updated).toBeGreaterThanOrEqual(1);
// Value should now point at the imported hash.
const v = dst.var.get("@test/config", schemaHash);
expect(v?.value).toBe(aHash);
});
test("2.13 import: tags are applied to imported nodes", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "tagged");
src.var.set("@test/c", aHash);
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
await importBundle(out, dst);
const tags = dst.tag.tags(aHash);
expect(tags.some((t) => t.key === "env" && t.value === "prod")).toBe(true);
});
test("2.14 import: stats report nodes/vars/tags counts", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/c", aHash);
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/c"], out);
const dst = createMemoryStore();
bootstrap(dst);
const stats = await importBundle(out, dst);
expect(stats.nodes.imported).toBeGreaterThan(0);
expect(stats.nodes.skipped).toBeGreaterThanOrEqual(0);
expect(stats.vars.created + stats.vars.updated).toBeGreaterThan(0);
expect(stats.tags).toBeGreaterThanOrEqual(1);
});
test("2.15 loadBundleStore: read-only Store from tar", async () => {
const src = createMemoryStore();
bootstrap(src);
const schemaHash = putSchema(src, { type: "string" });
const aHash = src.cas.put(schemaHash, "v");
src.var.set("@test/config", aHash);
src.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/config"], out);
const bundleStore = await loadBundleStore(out);
expect(bundleStore.cas.get(aHash)).not.toBeNull();
expect(bundleStore.cas.has(aHash)).toBe(true);
const v = bundleStore.var.get("@test/config");
expect(v?.value).toBe(aHash);
const tags = bundleStore.tag.tags(aHash);
expect(tags.some((t) => t.key === "env" && t.value === "prod")).toBe(true);
});
test("2.16 loadBundleStore: walk works against bundle store", async () => {
const src = createMemoryStore();
bootstrap(src);
const refSchema = putSchema(src, {
type: "object",
properties: { next: { type: "string", format: "ocas_ref" } },
});
const stringSchema = putSchema(src, { type: "string" });
const bHash = src.cas.put(stringSchema, "b-content");
const aHash = src.cas.put(refSchema, { next: bHash });
src.var.set("@test/root", aHash);
const out = join(tmpDir, "bundle.tar");
await exportBundle(src, ["@test/root"], out);
const bundleStore = await loadBundleStore(out);
const { walk } = await import("./schema.js");
const visited: string[] = [];
walk(bundleStore, aHash, (h) => visited.push(h));
expect(visited).toContain(aHash);
expect(visited).toContain(bHash);
});
});
// ---- Tar parser (minimal POSIX/ustar reader) used by tests ----
type TarEntry = { name: string; content: Uint8Array };
function readTarEntries(buf: Buffer): TarEntry[] {
const entries: TarEntry[] = [];
let offset = 0;
while (offset + 512 <= buf.length) {
const header = buf.subarray(offset, offset + 512);
// End-of-archive: two consecutive zero blocks.
if (header.every((b) => b === 0)) break;
const name = readCString(header, 0, 100);
const sizeStr = readCString(header, 124, 12).trim();
const size = sizeStr === "" ? 0 : parseInt(sizeStr, 8);
offset += 512;
const content = buf.subarray(offset, offset + size);
entries.push({ name, content: new Uint8Array(content) });
// Pad to 512-byte boundary.
offset += Math.ceil(size / 512) * 512;
}
return entries;
}
function listTarEntries(buf: Buffer): string[] {
return readTarEntries(buf).map((e) => e.name);
}
function readCString(buf: Buffer, start: number, len: number): string {
const slice = buf.subarray(start, start + len);
let end = slice.length;
for (let i = 0; i < slice.length; i++) {
if (slice[i] === 0) {
end = i;
break;
}
}
return slice.subarray(0, end).toString("utf8");
}
// Suppress unused import warnings.
void writeFileSync;
+398
View File
@@ -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/<hash>.bin`), variables
* (`vars.jsonl`), and tags (`tags.jsonl`).
*/
export async function exportBundle(
store: Store,
roots: string[],
outputPath: string,
): Promise<ExportStats> {
// Resolve every root before computing the closure so missing names error
// early.
const rootHashes = roots.map((r) => resolveRoot(store, r));
const closure = computeClosure(store, rootHashes);
const entries: TarEntry[] = [];
// CAS nodes — one CBOR-encoded file per node, named by hash.
// Order is deterministic by sorted hash.
const sortedNodes = [...closure.nodes].sort();
for (const hash of sortedNodes) {
const node = store.cas.get(hash);
if (!node) continue;
const content = cborEncode({
type: node.type,
payload: node.payload,
timestamp: node.timestamp,
});
entries.push({ name: `cas/${hash}.bin`, content });
}
// Variables — JSON-lines.
const sortedVars = [...closure.vars].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
const varLines = sortedVars
.map((v) =>
JSON.stringify({
name: v.name,
schema: v.schema,
value: v.value,
created: v.created,
updated: v.updated,
tags: v.tags,
labels: v.labels,
}),
)
.join("\n");
entries.push({
name: "vars.jsonl",
content: new TextEncoder().encode(
varLines + (varLines.length > 0 ? "\n" : ""),
),
});
// Tags — JSON-lines, one per tag.
const tagLines: string[] = [];
const sortedTagTargets = [...closure.tags.keys()].sort();
let tagCount = 0;
for (const target of sortedTagTargets) {
const tagList = closure.tags.get(target) ?? [];
for (const t of tagList) {
tagLines.push(
JSON.stringify({
target: t.target,
key: t.key,
value: t.value,
created: t.created,
}),
);
tagCount++;
}
}
const tagText = tagLines.join("\n");
entries.push({
name: "tags.jsonl",
content: new TextEncoder().encode(
tagText + (tagText.length > 0 ? "\n" : ""),
),
});
// Pack into tar and write to disk.
const tar = packTar(entries);
writeFileSync(outputPath, tar);
return {
nodes: sortedNodes.length,
vars: sortedVars.length,
tags: tagCount,
};
}
/**
* Read a bundle tar archive from disk, returning the parsed components
* without applying them to a store.
*/
function readBundle(bundlePath: string): {
nodes: Map<Hash, CasNode>;
vars: Variable[];
tags: Tag[];
} {
const buf = readFileSync(bundlePath);
const entries = unpackTar(buf);
const nodes = new Map<Hash, CasNode>();
let vars: Variable[] = [];
let tags: Tag[] = [];
for (const entry of entries) {
if (entry.name.startsWith("cas/") && entry.name.endsWith(".bin")) {
const hash = entry.name.slice(4, -4) as Hash;
const node = decode(entry.content) as CasNode;
nodes.set(hash, node);
} else if (entry.name === "vars.jsonl") {
const text = new TextDecoder().decode(entry.content);
vars = text
.split("\n")
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as Variable);
} else if (entry.name === "tags.jsonl") {
const text = new TextDecoder().decode(entry.content);
tags = text
.split("\n")
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as Tag);
}
}
return { nodes, vars, tags };
}
/**
* Apply scope remapping to a variable name. `@ocas/*` is reserved and never
* remapped. Other names get `^@[^/]+` replaced with the new scope.
*/
function remapVarName(name: string, scope: string | undefined): string {
if (scope === undefined) return name;
if (name.startsWith(BUILTIN_PREFIX)) return name;
// Replace leading @scope with the new scope. The format is `@scope/rest`.
return name.replace(/^@[^/]+/, scope);
}
/**
* Read a bundle from disk and apply its contents to `target`.
*/
export async function importBundle(
bundlePath: string,
target: Store,
options?: ImportOptions,
): Promise<ImportStats> {
// Ensure target is bootstrapped so meta-schema is available (importing the
// meta-schema as a regular CAS node would still work since hash-equal
// self-referencing nodes dedup).
bootstrap(target);
const { nodes, vars, tags } = readBundle(bundlePath);
// Sort nodes so that meta-schema (self-referencing) is imported first,
// then types (whose `type` is the meta-schema), then leaves. The simple
// heuristic: import nodes whose `type` is already present (or self) until
// the queue stabilises.
let imported = 0;
let skipped = 0;
const remaining = new Map(nodes);
let progress = true;
while (remaining.size > 0 && progress) {
progress = false;
for (const [hash, node] of [...remaining]) {
const ready = node.type === hash || target.cas.has(node.type);
if (!ready) continue;
if (target.cas.has(hash)) {
skipped++;
} else if (node.type === hash) {
// Self-referencing meta — import via bootstrap-capable interface.
// Fall back to put if the store doesn't expose BOOTSTRAP_STORE.
const cas = target.cas as unknown as {
[k: symbol]: ((p: unknown) => Hash) | undefined;
};
const 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<Store> {
const store = createMemoryStore();
// Apply the bundle's contents but suppress the bootstrap-only nodes so
// the bundle file remains the source of truth.
await importBundle(bundlePath, store);
return store;
}
// ---------------------------------------------------------------------------
// Minimal tar pack/unpack — POSIX ustar format, regular files only.
// ---------------------------------------------------------------------------
type TarEntry = { name: string; content: Uint8Array };
function packTar(entries: TarEntry[]): Buffer {
const blocks: Buffer[] = [];
for (const entry of entries) {
const header = Buffer.alloc(512);
writeString(header, entry.name, 0, 100);
writeOctal(header, 0o644, 100, 8);
writeOctal(header, 0, 108, 8);
writeOctal(header, 0, 116, 8);
writeOctal(header, entry.content.length, 124, 12);
writeOctal(header, Math.floor(Date.now() / 1000), 136, 12);
// checksum placeholder — 8 spaces, then computed.
for (let i = 0; i < 8; i++) header[148 + i] = 0x20;
header[156] = 0x30; // typeflag '0' (regular file)
writeString(header, "ustar ", 257, 8); // GNU-style ustar magic+version
let cksum = 0;
for (let i = 0; i < 512; i++) cksum += header[i] as number;
writeOctal(header, cksum, 148, 7);
header[155] = 0;
blocks.push(header);
const content = Buffer.from(entry.content);
blocks.push(content);
// Pad to 512.
const pad = (512 - (content.length % 512)) % 512;
if (pad > 0) blocks.push(Buffer.alloc(pad));
}
// End-of-archive: two zero blocks.
blocks.push(Buffer.alloc(512));
blocks.push(Buffer.alloc(512));
return Buffer.concat(blocks);
}
function unpackTar(buf: Buffer): TarEntry[] {
const entries: TarEntry[] = [];
let offset = 0;
while (offset + 512 <= buf.length) {
const header = buf.subarray(offset, offset + 512);
if (header.every((b) => b === 0)) break;
const name = readCString(header, 0, 100);
const sizeStr = readCString(header, 124, 12).trim();
const size = sizeStr === "" ? 0 : parseInt(sizeStr, 8);
offset += 512;
const content = new Uint8Array(buf.subarray(offset, offset + size));
entries.push({ name, content });
offset += Math.ceil(size / 512) * 512;
}
return entries;
}
function writeString(
buf: Buffer,
str: string,
offset: number,
len: number,
): void {
const data = Buffer.from(str, "utf8");
const n = Math.min(data.length, len);
data.copy(buf, offset, 0, n);
for (let i = n; i < len; i++) buf[offset + i] = 0;
}
function writeOctal(
buf: Buffer,
value: number,
offset: number,
len: number,
): void {
const str = value.toString(8).padStart(len - 1, "0");
writeString(buf, str, offset, len - 1);
buf[offset + len - 1] = 0;
}
function readCString(buf: Buffer, start: number, len: number): string {
const slice = buf.subarray(start, start + len);
let end = slice.length;
for (let i = 0; i < slice.length; i++) {
if (slice[i] === 0) {
end = i;
break;
}
}
return slice.subarray(0, end).toString("utf8");
}
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, test } from "vitest";
import { bootstrap } from "./bootstrap.js";
import { computeClosure } from "./closure.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
describe("computeClosure", () => {
test("1.1 basic node traversal — collects A, B, C linked by ocas_ref", () => {
const store = createMemoryStore();
bootstrap(store);
const refSchema = putSchema(store, {
type: "object",
properties: {
next: { type: "string", format: "ocas_ref" },
name: { type: "string" },
},
});
const stringSchema = putSchema(store, { type: "string" });
const cHash = store.cas.put(stringSchema, "leaf-c");
const bHash = store.cas.put(refSchema, { next: cHash, name: "b" });
const aHash = store.cas.put(refSchema, { next: bHash, name: "a" });
const result = computeClosure(store, [aHash]);
expect(result.nodes.has(aHash)).toBe(true);
expect(result.nodes.has(bHash)).toBe(true);
expect(result.nodes.has(cHash)).toBe(true);
});
test("1.2 schema chain inclusion — schema and meta-schema are part of the closure", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const metaHash = aliases["@ocas/schema"] as string;
const schemaHash = putSchema(store, { type: "object" });
const nodeHash = store.cas.put(schemaHash, { foo: "bar" });
const result = computeClosure(store, [nodeHash]);
expect(result.nodes.has(nodeHash)).toBe(true);
expect(result.nodes.has(schemaHash)).toBe(true);
expect(result.nodes.has(metaHash)).toBe(true);
});
test("1.3 template variable nodes — template content is included", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"] as string;
const schemaHash = putSchema(store, { type: "object" });
const nodeHash = store.cas.put(schemaHash, { x: 1 });
// Register a template for schemaHash
const templateContent = "rendered: {{ x }}";
const contentHash = store.cas.put(stringHash, templateContent);
store.var.set(`@ocas/template/text/${schemaHash}`, contentHash);
const result = computeClosure(store, [nodeHash]);
expect(result.nodes.has(nodeHash)).toBe(true);
expect(result.nodes.has(schemaHash)).toBe(true);
expect(result.nodes.has(contentHash)).toBe(true);
const templateVarNames = result.vars.map((v) => v.name);
expect(templateVarNames).toContain(`@ocas/template/text/${schemaHash}`);
});
test("1.4 multiple roots — union of closures", () => {
const store = createMemoryStore();
bootstrap(store);
const stringSchema = putSchema(store, { type: "string" });
const aHash = store.cas.put(stringSchema, "alpha");
const bHash = store.cas.put(stringSchema, "beta");
store.var.set("@test/a", aHash);
store.var.set("@test/b", bHash);
const result = computeClosure(store, [aHash, bHash]);
expect(result.nodes.has(aHash)).toBe(true);
expect(result.nodes.has(bHash)).toBe(true);
});
test("1.5 cycle handling — terminates on self-references", () => {
const store = createMemoryStore();
bootstrap(store);
const refSchema = putSchema(store, {
type: "object",
properties: {
next: { type: "string", format: "ocas_ref" },
},
});
// Build a self-loop by hashing first then storing
const stringSchema = putSchema(store, { type: "string" });
const placeholder = store.cas.put(stringSchema, "self");
// Create a cycle A -> B -> A
const bHash = store.cas.put(refSchema, { next: placeholder });
const aHash = store.cas.put(refSchema, { next: bHash });
// Mutate B to point back to A is impossible in CAS — instead test that
// the same node is visited only once even if reached via multiple paths.
const result = computeClosure(store, [aHash, aHash]);
expect(result.nodes.has(aHash)).toBe(true);
expect(result.nodes.has(bHash)).toBe(true);
// The placeholder is reached from B
expect(result.nodes.has(placeholder)).toBe(true);
// Each node appears exactly once in the set
expect(result.nodes.size).toBeGreaterThan(0);
});
test("1.6 variables pointing into closure are collected", () => {
const store = createMemoryStore();
bootstrap(store);
const stringSchema = putSchema(store, { type: "string" });
const xHash = store.cas.put(stringSchema, "x-content");
const yHash = store.cas.put(stringSchema, "y-content");
store.var.set("@test/x", xHash);
store.var.set("@test/y", yHash);
const result = computeClosure(store, [xHash]);
const names = result.vars.map((v) => v.name);
expect(names).toContain("@test/x");
expect(names).not.toContain("@test/y");
});
test("1.7 @ocas/* builtin vars whose values are in closure are collected", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const metaHash = aliases["@ocas/schema"] as string;
const result = computeClosure(store, [metaHash]);
// @ocas/schema is a builtin var pointing to metaHash
const names = result.vars.map((v) => v.name);
expect(names).toContain("@ocas/schema");
});
test("1.8 tags on closure nodes are collected", () => {
const store = createMemoryStore();
bootstrap(store);
const stringSchema = putSchema(store, { type: "string" });
const aHash = store.cas.put(stringSchema, "tagged-a");
const bHash = store.cas.put(stringSchema, "tagged-b");
store.tag.tag(aHash, [{ op: "set", key: "env", value: "prod" }]);
store.tag.tag(bHash, [{ op: "set", key: "env", value: "dev" }]);
const result = computeClosure(store, [aHash]);
const aTags = result.tags.get(aHash);
expect(aTags).toBeDefined();
expect(aTags?.some((t) => t.key === "env" && t.value === "prod")).toBe(
true,
);
// B is not in the closure
expect(result.tags.has(bHash)).toBe(false);
});
test("1.9 empty roots → empty closure", () => {
const store = createMemoryStore();
bootstrap(store);
const result = computeClosure(store, []);
expect(result.nodes.size).toBe(0);
expect(result.vars).toEqual([]);
expect(result.tags.size).toBe(0);
});
test("1.10 template content for any schema in closure is included", () => {
const store = createMemoryStore();
const aliases = bootstrap(store);
const stringHash = aliases["@ocas/string"] as string;
// schema A has template, schema B does not — both reachable via refs
const refSchema = putSchema(store, {
type: "object",
properties: {
next: { type: "string", format: "ocas_ref" },
},
});
const innerSchema = putSchema(store, { type: "object" });
const innerNode = store.cas.put(innerSchema, { x: 1 });
const outerNode = store.cas.put(refSchema, { next: innerNode });
const tplA = store.cas.put(stringHash, "A:{{ next }}");
const tplInner = store.cas.put(stringHash, "INNER");
store.var.set(`@ocas/template/text/${refSchema}`, tplA);
store.var.set(`@ocas/template/text/${innerSchema}`, tplInner);
const result = computeClosure(store, [outerNode]);
expect(result.nodes.has(tplA)).toBe(true);
expect(result.nodes.has(tplInner)).toBe(true);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { walk } from "./schema.js";
import type { Hash, Store, Tag } from "./types.js";
import type { Variable } from "./variable.js";
/**
* Result of a closure computation: the set of CAS hashes reachable from a
* set of roots, along with the variables and tags that point into the
* closure.
*/
export type ClosureResult = {
/** All CAS node hashes reachable from the roots. */
nodes: Set<Hash>;
/** Variables whose value is in the closure (excluding orphaned vars). */
vars: Variable[];
/** Tags grouped by their target hash (only targets in the closure). */
tags: Map<Hash, Tag[]>;
};
/**
* Compute the transitive closure starting from a set of root CAS hashes.
*
* The closure is a self-contained subset of a Store: every node it points
* at via `ocas_ref` fields, every schema it depends on (the meta-schema
* chain), and every template variable referencing a schema in the closure
* is included.
*
* Variables that point at hashes in the closure (after node and template
* walks) are returned. Tags whose target is in the closure are returned.
*
* Roots that do not exist in the store are silently skipped — callers
* (e.g. `exportBundle`) should validate roots beforehand if strictness is
* required.
*/
export function computeClosure(store: Store, roots: Hash[]): ClosureResult {
const nodes = new Set<Hash>();
// Phase 1: walk refs from each root.
for (const root of roots) {
if (!store.cas.has(root)) continue;
walk(store, root, (hash, node) => {
nodes.add(hash);
nodes.add(node.type);
});
}
// Phase 2: walk the schema chain to include meta-schemas (e.g. @ocas/schema)
// and any other type ancestors.
const schemasToWalk = new Set<Hash>();
for (const hash of nodes) {
const node = store.cas.get(hash);
if (node) schemasToWalk.add(node.type);
}
for (const schemaHash of schemasToWalk) {
let current: Hash | null = schemaHash;
while (current !== null && !nodes.has(current)) {
nodes.add(current);
const node = store.cas.get(current);
if (!node || node.type === current) break;
current = node.type;
}
}
// Phase 3: collect template variables for each schema in the closure.
// Templates are stored as `@ocas/template/text/<schema-hash>` variables.
// If a template exists for a schema in the closure, walk its content too.
const templateVars: Variable[] = [];
// Snapshot existing schema list — we may add nodes during template walks
const initialNodes = [...nodes];
for (const hash of initialNodes) {
const templateName = `@ocas/template/text/${hash}`;
const variants = store.var.list({ exactName: templateName });
for (const variant of variants) {
templateVars.push(variant);
// Walk the template content node
walk(store, variant.value, (h, n) => {
nodes.add(h);
nodes.add(n.type);
});
// And its schema chain
const tNode = store.cas.get(variant.value);
if (tNode) {
let current: Hash | null = tNode.type;
while (current !== null && !nodes.has(current)) {
nodes.add(current);
const node = store.cas.get(current);
if (!node || node.type === current) break;
current = node.type;
}
}
}
}
// Phase 4: collect variables whose value is in the closure. Template
// variables are already collected; deduplicate.
const varKey = (v: Variable): string => `${v.name}\u0000${v.schema}`;
const seenVars = new Set<string>(templateVars.map(varKey));
const vars: Variable[] = [...templateVars];
const allVars = store.var.list();
for (const v of allVars) {
if (!nodes.has(v.value)) continue;
const key = varKey(v);
if (seenVars.has(key)) continue;
seenVars.add(key);
vars.push(v);
}
// Phase 5: collect tags for each node in the closure.
const tags = new Map<Hash, Tag[]>();
for (const hash of nodes) {
const tagList = store.tag.tags(hash);
if (tagList.length > 0) {
tags.set(hash, tagList);
}
}
return { nodes, vars, tags };
}
+4 -4
View File
@@ -269,7 +269,7 @@ describe("bootstrap", () => {
);
});
test("returns a map with 30 built-in schema aliases", async () => {
test("returns a map with built-in schema aliases", async () => {
const store = createMemoryStore();
const builtinSchemas = bootstrap(store);
@@ -289,7 +289,7 @@ describe("bootstrap", () => {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(Object.keys(builtinSchemas)).toHaveLength(31);
expect(Object.keys(builtinSchemas)).toHaveLength(33);
});
test("meta-schema node is stored and retrievable", async () => {
@@ -326,7 +326,7 @@ describe("bootstrap", () => {
const h2 = bootstrap(store);
expect(h1).toEqual(h2);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 22 outputs)
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 24 outputs)
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(32);
});
});
+9
View File
@@ -1,7 +1,16 @@
export { bootstrap } from "./bootstrap.js";
export type { BootstrapCapableStore } from "./bootstrap-capable.js";
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
export {
type ExportStats,
exportBundle,
type ImportOptions,
type ImportStats,
importBundle,
loadBundleStore,
} from "./bundle.js";
export { cborEncode } from "./cbor.js";
export { type ClosureResult, computeClosure } from "./closure.js";
export {
CasNodeNotFoundError,
InvalidTagFormatError,
+1 -1
View File
@@ -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);
});
});