From 87c4a9c1f237d2ee6160965dbdc7c1af31bd45db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 1 Jun 2026 11:13:14 +0000 Subject: [PATCH] feat: resolveHash from varStore, fix double bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveTypeHash() → resolveHash(input, varStore): sync, queries varStore - Extract prepareStore() to avoid double bootstrap in openVarStore - openStoreAndVarStore() bootstraps exactly once - Remove builtinSchemas in-memory map Fixes #18 --- packages/cli/src/index.ts | 194 ++++++++++++++++++++++---------------- packages/fs/src/index.ts | 2 +- packages/fs/src/store.ts | 40 +++++--- 3 files changed, 141 insertions(+), 95 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2476896..46050a8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,7 +24,7 @@ import { walk, wrapEnvelope, } from "@ocas/core"; -import { openStore as openFsStore } from "@ocas/fs"; +import { openStore as openFsStore, prepareStore } from "@ocas/fs"; // ---- Argument parsing ---- @@ -163,32 +163,39 @@ async function openStore(varStore?: VariableStore): Promise { return await openFsStore(fullPath, varStore); } -async function openVarStore(): Promise { - const store = await openStore(); +async function openStoreAndVarStore(): Promise<{ + store: Store; + varStore: VariableStore; +}> { + const fullPath = resolve(storePath); + const store = await prepareStore(fullPath); const varStore = createVariableStore(resolve(varDbPath), store); - // Populate varStore with builtin schema aliases (idempotent). await bootstrap(store, varStore); - return varStore; + return { store, varStore }; } /** - * Resolve a type-hash, handling @ aliases via varStore lookup. + * Hash format check: 13-char uppercase Crockford Base32. */ -async function resolveTypeHash(typeHashOrAlias: string): Promise { - if (typeHashOrAlias.startsWith("@")) { - const varStore = await openVarStore(); - try { - const variants = varStore.list({ exactName: typeHashOrAlias }); - const first = variants[0]; - if (!first) { - die(`Schema not found: ${typeHashOrAlias}`); - } - return first.value; - } finally { - varStore.close(); - } +function isHash(input: string): boolean { + return /^[0-9A-Z]{13}$/.test(input); +} + +/** + * Resolve a hash-or-name. If `input` already looks like a hash, return it as-is. + * Otherwise, query the provided varStore for a variable with that exact name + * and return the first match's value. + */ +function resolveHash(input: string, varStore: VariableStore): Hash { + if (isHash(input)) { + return input as Hash; } - return typeHashOrAlias; + const variants = varStore.list({ exactName: input }); + const first = variants[0]; + if (!first) { + die(`Schema not found: ${input}`); + } + return first.value as Hash; } /** @@ -235,44 +242,50 @@ async function cmdPut(args: string[]): Promise { ); if (isPipe && args[1]) die("Cannot use --pipe/-p with a file argument. Use one or the other."); - const typeHash = await resolveTypeHash(typeHashOrAlias); - const payload = isPipe ? await readStdinJson() : readJsonFile(file as string); - const store = await openStore(); + const { store, varStore } = await openStoreAndVarStore(); + try { + const typeHash = resolveHash(typeHashOrAlias, varStore); + const payload = isPipe + ? await readStdinJson() + : readJsonFile(file as string); - // Schema nodes: use putSchema() which validates via isValidSchema() (recursive) - // instead of ajv against meta-schema (which can't express recursive constraints) - const metaHash = await resolveTypeHash("@ocas/schema"); - if (typeHash === metaHash) { - try { - const hash = await putSchema(store, payload as Record); - await out(await wrapEnvelope(store, "@ocas/output/put", hash), store); - } catch (_e) { + // Schema nodes: use putSchema() which validates via isValidSchema() (recursive) + // instead of ajv against meta-schema (which can't express recursive constraints) + const metaHash = resolveHash("@ocas/schema", varStore); + if (typeHash === metaHash) { + try { + const hash = await putSchema(store, payload as Record); + await out(await wrapEnvelope(store, "@ocas/output/put", hash), store); + } catch (_e) { + console.error( + `Validation failed: payload in ${file} does not match schema ${typeHash}`, + ); + process.exit(1); + } + return; + } + + // Check if schema exists + const schema = getSchema(store, typeHash); + if (schema === null) { + console.error(`Schema not found: ${typeHash}`); + process.exit(1); + } + + // Validate payload against schema before storing + const tempNode = { type: typeHash, payload, timestamp: Date.now() }; + if (!validate(store, tempNode)) { console.error( `Validation failed: payload in ${file} does not match schema ${typeHash}`, ); process.exit(1); } - return; - } - // Check if schema exists - const schema = getSchema(store, typeHash); - if (schema === null) { - console.error(`Schema not found: ${typeHash}`); - process.exit(1); + const hash = await store.put(typeHash, payload); + await out(await wrapEnvelope(store, "@ocas/output/put", hash), store); + } finally { + varStore.close(); } - - // Validate payload against schema before storing - const tempNode = { type: typeHash, payload, timestamp: Date.now() }; - if (!validate(store, tempNode)) { - console.error( - `Validation failed: payload in ${file} does not match schema ${typeHash}`, - ); - process.exit(1); - } - - const hash = await store.put(typeHash, payload); - await out(await wrapEnvelope(store, "@ocas/output/put", hash), store); } async function cmdGet(args: string[]): Promise { @@ -376,11 +389,17 @@ async function cmdHash(args: string[]): Promise { ); if (isPipe && args[1]) die("Cannot use --pipe/-p with a file argument. Use one or the other."); - const typeHash = await resolveTypeHash(typeHashOrAlias); - const payload = isPipe ? await readStdinJson() : readJsonFile(file as string); - const hash = await computeHash(typeHash, payload); - const store = await openStore(); - await out(await wrapEnvelope(store, "@ocas/output/hash", hash), store); + const { store, varStore } = await openStoreAndVarStore(); + try { + const typeHash = resolveHash(typeHashOrAlias, varStore); + const payload = isPipe + ? await readStdinJson() + : readJsonFile(file as string); + const hash = await computeHash(typeHash, payload); + await out(await wrapEnvelope(store, "@ocas/output/hash", hash), store); + } finally { + varStore.close(); + } } async function cmdRender(args: string[]): Promise { @@ -397,7 +416,14 @@ async function cmdRender(args: string[]): Promise { ); } - const store = await openStore(); + let storeAndVarStore: { store: Store; varStore: VariableStore } | undefined; + let store: Store; + if (isPipe) { + store = await openStore(); + } else { + storeAndVarStore = await openStoreAndVarStore(); + store = storeAndVarStore.store; + } // Parse numeric options const resolution = @@ -472,15 +498,21 @@ async function cmdRender(args: string[]): Promise { ); process.stdout.write(output); } else { - const varStore = await openVarStore(); - const output = await renderAsync(store, hash, { - resolution, - decay, - epsilon, - varStore, - }); - // Output to stdout without JSON wrapping (raw output) - process.stdout.write(output); + const varStore = ( + storeAndVarStore as { store: Store; varStore: VariableStore } + ).varStore; + try { + const output = await renderAsync(store, hash, { + resolution, + decay, + epsilon, + varStore, + }); + // Output to stdout without JSON wrapping (raw output) + process.stdout.write(output); + } finally { + varStore.close(); + } } } catch (error) { if (error instanceof CasNodeNotFoundError) { @@ -711,8 +743,7 @@ async function cmdTemplateSet(args: string[]): Promise { die("Usage: ocas template set | --inline "); } - const store = await openStore(); - const varStore = createVariableStore(resolve(varDbPath), store); + const { store, varStore } = await openStoreAndVarStore(); try { // Validate schema hash exists in CAS @@ -750,7 +781,7 @@ async function cmdTemplateSet(args: string[]): Promise { } // Store content in CAS under @string schema - const stringHash = await resolveTypeHash("@ocas/string"); + const stringHash = resolveHash("@ocas/string", varStore); const contentHash = await store.put(stringHash, content); // Create variable binding: @ocas/template/text/ @@ -781,12 +812,11 @@ async function cmdTemplateGet(args: string[]): Promise { die("Usage: ocas template get "); } - const store = await openStore(); - const varStore = createVariableStore(resolve(varDbPath), store); + const { store, varStore } = await openStoreAndVarStore(); try { const varName = `@ocas/template/text/${schemaHash}`; - const stringHash = await resolveTypeHash("@ocas/string"); + const stringHash = resolveHash("@ocas/string", varStore); const variable = varStore.get(varName, stringHash); if (variable === null) { @@ -813,11 +843,10 @@ async function cmdTemplateGet(args: string[]): Promise { } async function cmdTemplateList(_args: string[]): Promise { - const store = await openStore(); - const varStore = createVariableStore(resolve(varDbPath), store); + const { store, varStore } = await openStoreAndVarStore(); try { - const stringHash = await resolveTypeHash("@ocas/string"); + const stringHash = resolveHash("@ocas/string", varStore); const variables = varStore.list({ namePrefix: "@ocas/template/text/", schema: stringHash, @@ -844,12 +873,11 @@ async function cmdTemplateDelete(args: string[]): Promise { die("Usage: ocas template delete "); } - const store = await openStore(); - const varStore = createVariableStore(resolve(varDbPath), store); + const { store, varStore } = await openStoreAndVarStore(); try { const varName = `@ocas/template/text/${schemaHash}`; - const stringHash = await resolveTypeHash("@ocas/string"); + const stringHash = resolveHash("@ocas/string", varStore); varStore.remove(varName, stringHash); await out( @@ -884,10 +912,14 @@ async function cmdList(_args: string[]): Promise { const typeFlag = flags.type; if (typeof typeFlag !== "string") die("Usage: ocas list --type "); - const typeHash = await resolveTypeHash(typeFlag); - const store = await openStore(); - const hashes = Array.from(store.listByType(typeHash)); - await out(await wrapEnvelope(store, "@ocas/output/list", hashes), store); + const { store, varStore } = await openStoreAndVarStore(); + try { + const typeHash = resolveHash(typeFlag, varStore); + const hashes = Array.from(store.listByType(typeHash)); + await out(await wrapEnvelope(store, "@ocas/output/list", hashes), store); + } finally { + varStore.close(); + } } async function cmdListMeta(_args: string[]): Promise { diff --git a/packages/fs/src/index.ts b/packages/fs/src/index.ts index f3eafb5..349f8c8 100644 --- a/packages/fs/src/index.ts +++ b/packages/fs/src/index.ts @@ -1 +1 @@ -export { createFsStore, openStore } from "./store.js"; +export { createFsStore, openStore, prepareStore } from "./store.js"; diff --git a/packages/fs/src/store.ts b/packages/fs/src/store.ts index e14edb1..3fb5196 100644 --- a/packages/fs/src/store.ts +++ b/packages/fs/src/store.ts @@ -307,22 +307,18 @@ export function createFsStore(dir: string): BootstrapCapableStore { } /** - * 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) + * Prepare a filesystem-backed CAS store: create the directory (if needed), + * validate that the path is a directory, and instantiate the store. Does NOT + * run bootstrap — callers that want bootstrap should either use {@link openStore} + * or call `bootstrap` themselves (useful when wiring a varStore before + * bootstrap to avoid running it twice). * * @param dir - The directory path for the store - * @param varStore - Optional variable store; when provided, builtin schema - * aliases are written to it during bootstrap * @returns A Promise resolving to the BootstrapCapableStore * @throws Error if the path exists but is not a directory */ -export async function openStore( +export async function prepareStore( dir: string, - varStore?: VariableStore, ): Promise { // Create directory if it doesn't exist try { @@ -356,11 +352,29 @@ export async function openStore( throw error; } - // Create the store - const store = createFsStore(dir); + return createFsStore(dir); +} +/** + * 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 + * @param varStore - Optional variable store; when provided, builtin schema + * aliases are written to it during bootstrap + * @returns A Promise resolving to the BootstrapCapableStore + * @throws Error if the path exists but is not a directory + */ +export async function openStore( + dir: string, + varStore?: VariableStore, +): Promise { + const store = await prepareStore(dir); // Bootstrap (idempotent) await bootstrap(store, varStore); - return store; }