From c8bf38cb81d7e865e99c3c4cfb9b97d9759822f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 31 May 2026 13:34:43 +0000 Subject: [PATCH] feat: auto-bootstrap CAS store on open (Phase 1a) Changes: 1. openStore() now async, auto-creates directory and bootstraps 2. Removed shouldCreate parameter and validateStoreExists() 3. All openStore() calls now awaited 4. Deleted init and bootstrap commands 5. Removed Issue #55 store validation tests (superseded by auto-bootstrap) 6. Enhanced error handling in openStore() for permission/path issues 7. Updated e2e snapshots after fixing missed await in cmdRender Bootstrap is idempotent. Core tests using createMemoryStore() unaffected. Fixes #73 Co-Authored-By: Claude Opus 4.6 --- .../src/__snapshots__/e2e.test.ts.snap | 2 - packages/cli-json-cas/src/cli.test.ts | 130 +----------------- packages/cli-json-cas/src/e2e.test.ts | 18 +-- packages/cli-json-cas/src/index.ts | 115 +++++----------- packages/json-cas-fs/src/index.ts | 2 +- packages/json-cas-fs/src/store.test.ts | 121 +++++++++++++++- packages/json-cas-fs/src/store.ts | 56 ++++++++ 7 files changed, 221 insertions(+), 223 deletions(-) diff --git a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap index c3aa2b8..54d4f51 100644 --- a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap +++ b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap @@ -264,8 +264,6 @@ exports[`Phase 7: Edge Cases 7.6 no subcommand shows help text 1`] = ` "Usage: json-cas [--store ] [--json] [args] Commands: - init Create store dir and write bootstrap seed - bootstrap Write meta-schema seed, print hash schema put Register schema, print type hash schema get Print schema JSON schema list List all schemas (name + hash) diff --git a/packages/cli-json-cas/src/cli.test.ts b/packages/cli-json-cas/src/cli.test.ts index f1da8d9..7e01712 100644 --- a/packages/cli-json-cas/src/cli.test.ts +++ b/packages/cli-json-cas/src/cli.test.ts @@ -1443,130 +1443,6 @@ describe("schema put - invalid schema error handling", () => { }); }); -describe("Store validation - non-existent paths (Issue #55)", () => { - test("E2E-55-1: get with non-existent store reports store not found", async () => { - const fakePath = "/nonexistent/path/to/store"; - const { exitCode, stderr } = await runCli( - ["get", "AAAAAAAAAAAAA"], - fakePath, - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Store not found"); - expect(stderr).toContain(fakePath); - expect(stderr).not.toContain("Node not found"); - }); - - test("E2E-55-2: has with non-existent store reports store not found", async () => { - const fakePath = `/tmp/nonexistent-store-${Date.now()}`; - const { exitCode, stderr } = await runCli( - ["has", "BBBBBBBBBBBBB"], - fakePath, - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Store not found"); - }); - - test("E2E-55-3: verify with non-existent store reports store not found", async () => { - const { exitCode, stderr } = await runCli( - ["verify", "CCCCCCCCCCCCC"], - "/does/not/exist", - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Store not found"); - }); - - test("E2E-55-4: schema get with non-existent store reports store not found", async () => { - const { exitCode, stderr } = await runCli( - ["schema", "get", "@string"], - "/fake/path", - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Store not found"); - }); - - test("E2E-55-5: schema list with non-existent store reports store not found", async () => { - const { exitCode, stderr } = await runCli( - ["schema", "list"], - "/nonexistent/store", - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Store not found"); - }); - - test("E2E-55-6: walk with non-existent store reports store not found", async () => { - const { exitCode, stderr } = await runCli( - ["walk", "DDDDDDDDDDDDD"], - "/tmp/missing-store", - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Store not found"); - }); - - test("E2E-55-7: cat with non-existent store reports store not found", async () => { - const { exitCode, stderr } = await runCli( - ["cat", "EEEEEEEEEEEEE"], - "/no/such/dir", - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Store not found"); - }); - - test("E2E-55-8: refs with non-existent store reports store not found", async () => { - const { exitCode, stderr } = await runCli( - ["refs", "FFFFFFFFFFFFF"], - "/nonexistent", - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Store not found"); - }); - - test("E2E-55-9: existing store works normally (regression)", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - const payloadFile = join(tmpStore, "test.json"); - writeFileSync(payloadFile, JSON.stringify("test")); - - const { stdout: hash } = await runCli( - ["put", "@string", payloadFile], - tmpStore, - ); - const { exitCode, stdout, stderr } = await runCli( - ["get", hash.trim()], - tmpStore, - ); - - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - const node = JSON.parse(stdout); - expect(node).toHaveProperty("type"); - expect(node).toHaveProperty("payload", "test"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("E2E-55-10: init creates directory (should not validate)", async () => { - const newStore = join(tmpdir(), `new-store-${Date.now()}`); - try { - const { exitCode, stdout, stderr } = await runCli(["init"], newStore); - - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - const { existsSync } = await import("node:fs"); - expect(existsSync(newStore)).toBe(true); - } finally { - rmSync(newStore, { recursive: true, force: true }); - } - }); -}); +// Store validation tests removed - with auto-bootstrap in Phase 1a, +// stores are automatically created and bootstrapped when opened. +// Issue #55 validation is no longer applicable. diff --git a/packages/cli-json-cas/src/e2e.test.ts b/packages/cli-json-cas/src/e2e.test.ts index 1e86b09..ea2a7f1 100644 --- a/packages/cli-json-cas/src/e2e.test.ts +++ b/packages/cli-json-cas/src/e2e.test.ts @@ -57,13 +57,7 @@ function stripVolatile(json: string): unknown { // ---- Phase 1: CAS Core ---- describe("Phase 1: CAS Core", () => { - test("1.1 bootstrap returns 13-char Base32 hash", async () => { - const { stdout, exitCode } = await runCli(["init"]); - expect(exitCode).toBe(0); - expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - }); - - test("1.2 schema put returns type hash", async () => { + test("1.1 schema put returns type hash (auto-bootstraps store)", async () => { const schemaFile = join(tmpStore, "test-schema.json"); writeFileSync( schemaFile, @@ -531,14 +525,15 @@ describe("Phase 7: Edge Cases", () => { expect(combined.toLowerCase()).toContain("usage"); }); - test("7.7 --store non-existent path errors with Store not found", async () => { - const fakeStore = "/nonexistent/store/path"; + test("7.7 --store path is a file errors", async () => { + const fileAsStore = join(tmpStore, "not-a-directory"); + writeFileSync(fileAsStore, "test"); const proc = Bun.spawn( [ "bun", entrypoint, "--store", - fakeStore, + fileAsStore, "--var-db", varDbPath, "get", @@ -549,7 +544,6 @@ describe("Phase 7: Edge Cases", () => { const exitCode = await proc.exited; const stderr = (await new Response(proc.stderr).text()).trim(); expect(exitCode).not.toBe(0); - expect(stderr).toContain("Store not found"); - expect(stderr).not.toContain("Node not found"); + expect(stderr).toContain("not a directory"); }); }); diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index da4989e..5f7281f 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas"; @@ -24,7 +24,7 @@ import { verify, walk, } from "@uncaged/json-cas"; -import { createFsStore } from "@uncaged/json-cas-fs"; +import { openStore as openFsStore } from "@uncaged/json-cas-fs"; // ---- Argument parsing ---- @@ -113,26 +113,16 @@ function readJsonFile(file: string): unknown { } /** - * Validate that the store directory exists. - * Dies with a clear error message if not found. + * Open the filesystem-backed CAS store. + * Automatically creates directory and bootstraps if needed. */ -function validateStoreExists(path: string): void { - if (!existsSync(path)) { - die(`Store not found at ${path}`); - } -} - -function openStore(shouldCreate = false): Store { +async function openStore(): Promise { const fullPath = resolve(storePath); - if (!shouldCreate) { - validateStoreExists(fullPath); - } - return createFsStore(fullPath); + return await openFsStore(fullPath); } -function openVarStore(): VariableStore { - const store = openStore(true); - mkdirSync(resolve(storePath), { recursive: true }); +async function openVarStore(): Promise { + const store = await openStore(); return createVariableStore(resolve(varDbPath), store); } @@ -141,12 +131,9 @@ function openVarStore(): VariableStore { * If the input starts with @, resolve it via bootstrap * Otherwise, return the hash as-is */ -async function resolveTypeHash( - typeHashOrAlias: string, - shouldCreate = false, -): Promise { +async function resolveTypeHash(typeHashOrAlias: string): Promise { if (typeHashOrAlias.startsWith("@")) { - const store = openStore(shouldCreate); + const store = await openStore(); const builtinSchemas = await bootstrap(store); const resolvedHash = builtinSchemas[typeHashOrAlias]; if (!resolvedHash) { @@ -162,7 +149,7 @@ async function resolveTypeHash( * This is the type hash used in JSON envelopes */ async function getVariableSchemaHash(): Promise { - const store = openStore(); + const store = await openStore(); // Define the Variable JSON Schema (updated for new model with composite key) const variableSchema: JSONSchema = { @@ -240,27 +227,11 @@ function parseTagsLabels(args: string[]): { // ---- Commands ---- -async function cmdInit(): Promise { - const dir = resolve(storePath); - mkdirSync(dir, { recursive: true }); - const store = createFsStore(dir); - const builtinSchemas = await bootstrap(store); - const metaHash = builtinSchemas["@schema"]; - console.log(metaHash); -} - -async function cmdBootstrap(): Promise { - const store = openStore(true); - const builtinSchemas = await bootstrap(store); - const metaHash = builtinSchemas["@schema"]; - console.log(metaHash); -} - async function cmdSchemaPut(args: string[]): Promise { const file = args[0]; if (!file) die("Usage: json-cas schema put "); const schema = readJsonFile(file) as JSONSchema; - const store = openStore(true); + const store = await openStore(); try { const hash = await putSchema(store, schema); console.log(hash); @@ -276,14 +247,14 @@ async function cmdSchemaGet(args: string[]): Promise { const hashOrAlias = args[0]; if (!hashOrAlias) die("Usage: json-cas schema get "); const hash = await resolveTypeHash(hashOrAlias); - const store = openStore(); + const store = await openStore(); const schema = getSchema(store, hash); if (schema === null) die(`Schema not found: ${hashOrAlias}`); out(schema); } async function cmdSchemaList(): Promise { - const store = openStore(); + const store = await openStore(); const builtinSchemas = await bootstrap(store); const metaHash = builtinSchemas["@schema"]; if (!metaHash) throw new Error("Meta-schema not found"); @@ -304,7 +275,7 @@ async function cmdSchemaList(): Promise { async function cmdSchemaValidate(args: string[]): Promise { const hash = args[0]; if (!hash) die("Usage: json-cas schema validate "); - const store = openStore(); + const store = await openStore(); const node = store.get(hash); if (node === null) die(`Node not found: ${hash}`); const valid = validate(store, node); @@ -316,9 +287,9 @@ async function cmdPut(args: string[]): Promise { const file = args[1]; if (!typeHashOrAlias || !file) die("Usage: json-cas put "); - const typeHash = await resolveTypeHash(typeHashOrAlias, true); + const typeHash = await resolveTypeHash(typeHashOrAlias); const payload = readJsonFile(file); - const store = openStore(true); + const store = await openStore(); // Check if schema exists const schema = getSchema(store, typeHash); @@ -343,7 +314,7 @@ async function cmdPut(args: string[]): Promise { async function cmdGet(args: string[]): Promise { const hash = args[0]; if (!hash) die("Usage: json-cas get "); - const store = openStore(); + const store = await openStore(); const node = store.get(hash); if (node === null) die(`Node not found: ${hash}`); out(node); @@ -352,14 +323,14 @@ async function cmdGet(args: string[]): Promise { async function cmdHas(args: string[]): Promise { const hash = args[0]; if (!hash) die("Usage: json-cas has "); - const store = openStore(); + const store = await openStore(); console.log(String(store.has(hash))); } async function cmdVerify(args: string[]): Promise { const hash = args[0]; if (!hash) die("Usage: json-cas verify "); - const store = openStore(); + const store = await openStore(); const node = store.get(hash); if (node === null) die(`Node not found: ${hash}`); const ok = await verify(hash, node); @@ -369,7 +340,7 @@ async function cmdVerify(args: string[]): Promise { async function cmdRefs(args: string[]): Promise { const hash = args[0]; if (!hash) die("Usage: json-cas refs "); - const store = openStore(); + const store = await openStore(); const node = store.get(hash); if (node === null) die(`Node not found: ${hash}`); const refHashes = refs(store, node); @@ -381,7 +352,7 @@ async function cmdRefs(args: string[]): Promise { async function cmdWalk(args: string[]): Promise { const hash = args[0]; if (!hash) die("Usage: json-cas walk [--format tree]"); - const store = openStore(); + const store = await openStore(); const format = flags.format; if (format === "tree") { @@ -422,7 +393,7 @@ async function cmdHash(args: string[]): Promise { const file = args[1]; if (!typeHashOrAlias || !file) die("Usage: json-cas hash "); - const typeHash = await resolveTypeHash(typeHashOrAlias, true); + const typeHash = await resolveTypeHash(typeHashOrAlias); const payload = readJsonFile(file); const hash = await computeHash(typeHash, payload); console.log(hash); @@ -442,7 +413,7 @@ async function cmdRender(args: string[]): Promise { ); } - const store = openStore(); + const store = await openStore(); // Parse numeric options const resolution = @@ -517,7 +488,7 @@ async function cmdRender(args: string[]): Promise { ); process.stdout.write(output); } else { - const varStore = openVarStore(); + const varStore = await openVarStore(); const output = await renderAsync(store, hash, { resolution, decay, @@ -541,7 +512,7 @@ async function cmdRender(args: string[]): Promise { async function cmdCat(args: string[]): Promise { const hash = args[0]; if (!hash) die("Usage: json-cas cat "); - const store = openStore(); + const store = await openStore(); const node = store.get(hash); if (node === null) die(`Node not found: ${hash}`); if (flags.payload === true) { @@ -560,7 +531,7 @@ async function cmdVarSet(args: string[]): Promise { die("Usage: json-cas var set [--tag ...]"); } - const varStore = openVarStore(); + const varStore = await openVarStore(); try { // Parse tags/labels from --tag flags @@ -611,7 +582,7 @@ async function cmdVarGet(args: string[]): Promise { die("Usage: json-cas var get --schema "); } - const varStore = openVarStore(); + const varStore = await openVarStore(); try { const variable = varStore.get(name, schema); @@ -633,7 +604,7 @@ async function cmdVarDelete(args: string[]): Promise { die("Usage: json-cas var delete [--schema ]"); } - const varStore = openVarStore(); + const varStore = await openVarStore(); try { if (schema !== undefined) { @@ -670,7 +641,7 @@ async function cmdVarTag(args: string[]): Promise { die("Usage: json-cas var tag --schema "); } - const varStore = openVarStore(); + const varStore = await openVarStore(); try { const { tags, labels, deleteNames } = parseTagsLabels(tagArgs); @@ -702,7 +673,7 @@ async function cmdVarList(args: string[]): Promise { const schema = flags.schema as string | undefined; const tagFlags = flags.tag; - const varStore = openVarStore(); + const varStore = await openVarStore(); try { // Parse tags/labels from --tag flags @@ -744,8 +715,7 @@ async function cmdTemplateSet(args: string[]): Promise { die("Usage: json-cas template set | --inline "); } - const store = openStore(); - mkdirSync(resolve(storePath), { recursive: true }); + const store = await openStore(); const varStore = createVariableStore(resolve(varDbPath), store); try { @@ -816,8 +786,7 @@ async function cmdTemplateGet(args: string[]): Promise { die("Usage: json-cas template get "); } - const store = openStore(); - mkdirSync(resolve(storePath), { recursive: true }); + const store = await openStore(); const varStore = createVariableStore(resolve(varDbPath), store); try { @@ -843,8 +812,7 @@ async function cmdTemplateGet(args: string[]): Promise { } async function cmdTemplateList(_args: string[]): Promise { - const store = openStore(); - mkdirSync(resolve(storePath), { recursive: true }); + const store = await openStore(); const varStore = createVariableStore(resolve(varDbPath), store); try { @@ -884,8 +852,7 @@ async function cmdTemplateDelete(args: string[]): Promise { die("Usage: json-cas template delete "); } - const store = openStore(); - mkdirSync(resolve(storePath), { recursive: true }); + const store = await openStore(); const varStore = createVariableStore(resolve(varDbPath), store); try { @@ -905,7 +872,7 @@ async function cmdTemplateDelete(args: string[]): Promise { } async function cmdGc(_args: string[]): Promise { - const store = createFsStore(storePath); + const store = await openStore(); const varStore = createVariableStore(varDbPath, store); try { @@ -921,8 +888,6 @@ function printUsage(): void { Usage: json-cas [--store ] [--json] [args] Commands: - init Create store dir and write bootstrap seed - bootstrap Write meta-schema seed, print hash schema put Register schema, print type hash schema get Print schema JSON schema list List all schemas (name + hash) @@ -971,14 +936,6 @@ if (!cmd) { } switch (cmd) { - case "init": - await cmdInit(); - break; - - case "bootstrap": - await cmdBootstrap(); - break; - case "schema": { const [sub, ...subRest] = rest; switch (sub) { diff --git a/packages/json-cas-fs/src/index.ts b/packages/json-cas-fs/src/index.ts index e226ff3..f3eafb5 100644 --- a/packages/json-cas-fs/src/index.ts +++ b/packages/json-cas-fs/src/index.ts @@ -1 +1 @@ -export { createFsStore } from "./store.js"; +export { createFsStore, openStore } from "./store.js"; diff --git a/packages/json-cas-fs/src/store.test.ts b/packages/json-cas-fs/src/store.test.ts index 0bde8ed..05b6176 100644 --- a/packages/json-cas-fs/src/store.test.ts +++ b/packages/json-cas-fs/src/store.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CasNode } from "@uncaged/json-cas"; @@ -10,7 +16,7 @@ import { verify, } from "@uncaged/json-cas"; -import { createFsStore } from "./store.js"; +import { createFsStore, openStore } from "./store.js"; function makeTmpDir(): string { return mkdtempSync(join(tmpdir(), "json-cas-fs-test-")); @@ -312,3 +318,114 @@ describe("createFsStore – verify on disk-loaded nodes", () => { } }); }); + +// ────────────────────────────────────────────────────────────────────────────── +// openStore – async with auto-bootstrap +// ────────────────────────────────────────────────────────────────────────────── +describe("openStore – async with auto-bootstrap", () => { + let dir: string; + beforeEach(() => { + dir = makeTmpDir(); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("openStore returns Promise", async () => { + const store = await openStore(dir); + expect(store).toBeDefined(); + expect(typeof store.put).toBe("function"); + expect(typeof store.get).toBe("function"); + }); + + test("openStore auto-creates directory when it doesn't exist", async () => { + const nested = join(dir, "sub", "nested", "store"); + expect(existsSync(nested)).toBe(false); + + const store = await openStore(nested); + expect(existsSync(nested)).toBe(true); + + // Verify store works + const typeHash = await computeSelfHash({ name: "t" }); + const hash = await store.put(typeHash, { x: 1 }); + expect(store.has(hash)).toBe(true); + }); + + test("openStore works when directory already exists", async () => { + // Pre-create the directory + const store1 = await openStore(dir); + const typeHash = await computeSelfHash({ name: "t" }); + await store1.put(typeHash, { x: 1 }); + + // Open again + const store2 = await openStore(dir); + expect(store2.listByType(typeHash)).toHaveLength(1); + }); + + test("openStore throws error when path exists but is not a directory", async () => { + const filePath = join(dir, "not-a-dir"); + writeFileSync(filePath, "test"); + + await expect(openStore(filePath)).rejects.toThrow(); + }); + + test("openStore auto-bootstraps on first open (empty directory)", async () => { + const store = await openStore(dir); + + // Check that bootstrap schemas exist + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"]; + + expect(metaHash).toBeDefined(); + expect(store.has(metaHash as string)).toBe(true); + + // Verify all core schemas exist + expect(store.has(builtinSchemas["@string"] as string)).toBe(true); + expect(store.has(builtinSchemas["@number"] as string)).toBe(true); + expect(store.has(builtinSchemas["@object"] as string)).toBe(true); + expect(store.has(builtinSchemas["@array"] as string)).toBe(true); + expect(store.has(builtinSchemas["@bool"] as string)).toBe(true); + expect(store.has(builtinSchemas["@schema"] as string)).toBe(true); + }); + + test("openStore bootstrap is idempotent on subsequent opens", async () => { + const store1 = await openStore(dir); + const schemas1 = await bootstrap(store1); + const count1 = store1.listAll().length; + + const store2 = await openStore(dir); + const schemas2 = await bootstrap(store2); + const count2 = store2.listAll().length; + + // Same schemas, same count + expect(schemas1).toEqual(schemas2); + expect(count1).toBe(count2); + }); + + test("openStore works on already-bootstrapped store", async () => { + // Bootstrap manually first + const store1 = createFsStore(dir); + const schemas1 = await bootstrap(store1); + + // Open with openStore + const store2 = await openStore(dir); + const schemas2 = await bootstrap(store2); + + expect(schemas1).toEqual(schemas2); + }); + + test("openStore auto-bootstraps old store without bootstrap", async () => { + // Create a store with some data but no bootstrap + const store1 = createFsStore(dir); + const typeHash = await computeSelfHash({ name: "custom" }); + await store1.put(typeHash, { data: "old" }); + + // Open with openStore - should auto-bootstrap + const store2 = await openStore(dir); + const schemas = await bootstrap(store2); + + expect(store2.has(schemas["@schema"] as string)).toBe(true); + // Old data still exists + expect(store2.listByType(typeHash)).toHaveLength(1); + }); +}); diff --git a/packages/json-cas-fs/src/store.ts b/packages/json-cas-fs/src/store.ts index 1eb540c..2d46e9f 100644 --- a/packages/json-cas-fs/src/store.ts +++ b/packages/json-cas-fs/src/store.ts @@ -5,6 +5,7 @@ import { readdirSync, readFileSync, renameSync, + statSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -13,6 +14,7 @@ import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas"; import { BOOTSTRAP_STORE, + bootstrap, cborEncode, computeHash, computeSelfHash, @@ -219,3 +221,57 @@ export function createFsStore(dir: string): BootstrapCapableStore { return store; } + +/** + * Open a filesystem-backed CAS store with automatic directory creation and bootstrap. + * This is an async function that: + * 1. Creates the directory (with recursive: true) if it doesn't exist + * 2. Validates that the path is actually a directory (not a file) + * 3. Creates the store + * 4. Runs bootstrap (which is idempotent) + * + * @param dir - The directory path for the store + * @returns A Promise resolving to the BootstrapCapableStore + * @throws Error if the path exists but is not a directory + */ +export async function openStore(dir: string): Promise { + // Create directory if it doesn't exist + try { + mkdirSync(dir, { recursive: true }); + } catch (error) { + if (error instanceof Error && "code" in error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "EACCES") { + throw new Error(`Permission denied: cannot access store at ${dir}`); + } + if (nodeError.code === "ENOTDIR") { + throw new Error(`Path exists but is not a directory: ${dir}`); + } + } + throw error; + } + + // Validate that the path is a directory + try { + const stats = statSync(dir); + if (!stats.isDirectory()) { + throw new Error(`Path exists but is not a directory: ${dir}`); + } + } catch (error) { + if (error instanceof Error && "code" in error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ENOENT") { + throw new Error(`Store not found at ${dir}`); + } + } + throw error; + } + + // Create the store + const store = createFsStore(dir); + + // Bootstrap (idempotent) + await bootstrap(store); + + return store; +} -- 2.43.0