From 20b6335f3339234a41e710bc1c497277139ad35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 1 Jun 2026 10:54:11 +0000 Subject: [PATCH] feat: bootstrap writes to varStore bootstrap() accepts optional varStore parameter. When provided, all builtin schema aliases are written via varStore.set(). Also registers previously missing @ocas/integer, @ocas/boolean, and @ocas/null. Fixes #17 --- packages/cli/src/index.ts | 94 +++++--- .../__snapshots__/edge-cases.test.ts.snap | 203 ++++++++++++++++++ packages/core/src/bootstrap.test.ts | 11 +- packages/core/src/bootstrap.ts | 32 ++- packages/core/src/index.test.ts | 11 +- packages/fs/src/store.test.ts | 2 +- packages/fs/src/store.ts | 18 +- 7 files changed, 325 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8008cd9..2476896 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -156,31 +156,37 @@ async function readStdinJson(): Promise { /** * Open the filesystem-backed CAS store. * Automatically creates directory and bootstraps if needed. + * If a varStore is provided, builtin schema aliases are written to it during bootstrap. */ -async function openStore(): Promise { +async function openStore(varStore?: VariableStore): Promise { const fullPath = resolve(storePath); - return await openFsStore(fullPath); + return await openFsStore(fullPath, varStore); } async function openVarStore(): Promise { const store = await openStore(); - return createVariableStore(resolve(varDbPath), store); + const varStore = createVariableStore(resolve(varDbPath), store); + // Populate varStore with builtin schema aliases (idempotent). + await bootstrap(store, varStore); + return varStore; } /** - * Resolve a type-hash, handling @ aliases - * If the input starts with @, resolve it via bootstrap - * Otherwise, return the hash as-is + * Resolve a type-hash, handling @ aliases via varStore lookup. */ async function resolveTypeHash(typeHashOrAlias: string): Promise { if (typeHashOrAlias.startsWith("@")) { - const store = await openStore(); - const builtinSchemas = await bootstrap(store); - const resolvedHash = builtinSchemas[typeHashOrAlias]; - if (!resolvedHash) { - die(`Schema not found: ${typeHashOrAlias}`); + 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(); } - return resolvedHash; } return typeHashOrAlias; } @@ -235,8 +241,7 @@ async function cmdPut(args: string[]): Promise { // Schema nodes: use putSchema() which validates via isValidSchema() (recursive) // instead of ajv against meta-schema (which can't express recursive constraints) - const builtinSchemas = await bootstrap(store); - const metaHash = builtinSchemas["@ocas/schema"]; + const metaHash = await resolveTypeHash("@ocas/schema"); if (typeHash === metaHash) { try { const hash = await putSchema(store, payload as Record); @@ -283,7 +288,10 @@ async function cmdHas(args: string[]): Promise { const hash = args[0]; if (!hash) die("Usage: ocas has "); const store = await openStore(); - await out(await wrapEnvelope(store, "@ocas/output/has", store.has(hash)), store); + await out( + await wrapEnvelope(store, "@ocas/output/has", store.has(hash)), + store, + ); } async function cmdVerify(args: string[]): Promise { @@ -345,7 +353,10 @@ async function cmdWalk(args: string[]): Promise { } printNode(hash, "", true); - await out(await wrapEnvelope(store, "@ocas/output/walk", lines.join("\n")), store); + await out( + await wrapEnvelope(store, "@ocas/output/walk", lines.join("\n")), + store, + ); } else { const hashes: Hash[] = []; walk(store, hash, (h) => { @@ -523,7 +534,10 @@ async function cmdVarSet(args: string[]): Promise { : undefined; const variable = varStore.set(name, value, options); - await out(await wrapEnvelope(store, "@ocas/output/var-set", variable), store); + await out( + await wrapEnvelope(store, "@ocas/output/var-set", variable), + store, + ); } catch (e) { if ( e instanceof InvalidVariableNameError || @@ -554,7 +568,10 @@ async function cmdVarGet(args: string[]): Promise { if (variable === null) { die(`Error: Variable not found: name=${name}, schema=${schema}`); } - await out(await wrapEnvelope(store, "@ocas/output/var-get", variable), store); + await out( + await wrapEnvelope(store, "@ocas/output/var-get", variable), + store, + ); } finally { varStore.close(); } @@ -579,11 +596,17 @@ async function cmdVarDelete(args: string[]): Promise { if (schema !== undefined) { // Precise deletion: remove specific (name, schema) variant const variable = varStore.remove(name, schema); - await out(await wrapEnvelope(store, "@ocas/output/var-delete", variable), store); + await out( + await wrapEnvelope(store, "@ocas/output/var-delete", variable), + store, + ); } else { // Batch deletion: remove all variants for this name const variables = varStore.remove(name); - await out(await wrapEnvelope(store, "@ocas/output/var-delete", variables), store); + await out( + await wrapEnvelope(store, "@ocas/output/var-delete", variables), + store, + ); } } catch (e) { if (e instanceof VariableNotFoundError) { @@ -620,7 +643,10 @@ async function cmdVarTag(args: string[]): Promise { delete: deleteNames.length > 0 ? deleteNames : undefined, }); - await out(await wrapEnvelope(store, "@ocas/output/var-tag", variable), store); + await out( + await wrapEnvelope(store, "@ocas/output/var-tag", variable), + store, + ); } catch (e) { if ( e instanceof VariableNotFoundError || @@ -663,7 +689,10 @@ async function cmdVarList(args: string[]): Promise { tags: Object.keys(tags).length > 0 ? tags : undefined, labels: labels.length > 0 ? labels : undefined, }); - await out(await wrapEnvelope(store, "@ocas/output/var-list", variables), store); + await out( + await wrapEnvelope(store, "@ocas/output/var-list", variables), + store, + ); } catch (e) { if (e instanceof InvalidVariableNameError) { die(`Error: ${e.message}`); @@ -733,7 +762,8 @@ async function cmdTemplateSet(args: string[]): Promise { schemaHash, contentHash, }), - store); + store, + ); } catch (e) { if (e instanceof CasNodeNotFoundError) { die(`Error: ${e.message}`); @@ -775,7 +805,8 @@ async function cmdTemplateGet(args: string[]): Promise { "@ocas/output/template-get", node.payload as string, ), - store); + store, + ); } finally { varStore.close(); } @@ -797,7 +828,10 @@ async function cmdTemplateList(_args: string[]): Promise { contentHash: v.value, })); - await out(await wrapEnvelope(store, "@ocas/output/template-list", templates), store); + await out( + await wrapEnvelope(store, "@ocas/output/template-list", templates), + store, + ); } finally { varStore.close(); } @@ -822,7 +856,8 @@ async function cmdTemplateDelete(args: string[]): Promise { await wrapEnvelope(store, "@ocas/output/template-delete", { deleted: true, }), - store); + store, + ); } catch (e) { if (e instanceof VariableNotFoundError) { die(`Error: Template not found for schema: ${schemaHash}`); @@ -864,7 +899,10 @@ async function cmdListMeta(_args: string[]): Promise { async function cmdListSchema(_args: string[]): Promise { const store = await openStore(); const hashes = store.listSchemas(); - await out(await wrapEnvelope(store, "@ocas/output/list-schema", hashes), store); + await out( + await wrapEnvelope(store, "@ocas/output/list-schema", hashes), + store, + ); } function printUsage(): void { @@ -1018,4 +1056,4 @@ switch (cmd) { default: die(`Unknown command: ${cmd}`); -} \ No newline at end of file +} diff --git a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap index 920d1fc..74bc3c7 100644 --- a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap +++ b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap @@ -81,6 +81,209 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` { "type": "AF0XACGXHPMC1", "value": [ + { + "labels": [], + "name": "@ocas/schema", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "CTS5P6RD8HMCS", + }, + { + "labels": [], + "name": "@ocas/string", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "7VQ43ZSJTEWA7", + }, + { + "labels": [], + "name": "@ocas/number", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "BEAZQGKVXMZT8", + }, + { + "labels": [], + "name": "@ocas/integer", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "B26JM4PBHPAFK", + }, + { + "labels": [], + "name": "@ocas/boolean", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "1AVHCXEJVDCPP", + }, + { + "labels": [], + "name": "@ocas/bool", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "1AVHCXEJVDCPP", + }, + { + "labels": [], + "name": "@ocas/object", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "944RT37WX1PQ5", + }, + { + "labels": [], + "name": "@ocas/array", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "D45CW047XS17Y", + }, + { + "labels": [], + "name": "@ocas/null", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "8E33KAS0HMAZ7", + }, + { + "labels": [], + "name": "@ocas/output/put", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "4ZHWK21APCFZ5", + }, + { + "labels": [], + "name": "@ocas/output/get", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "FB4K0SXG68ZFS", + }, + { + "labels": [], + "name": "@ocas/output/has", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "FHXQQZMVHW924", + }, + { + "labels": [], + "name": "@ocas/output/hash", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "1B24CBF95Q5G6", + }, + { + "labels": [], + "name": "@ocas/output/verify", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "52HEFB52BD0GF", + }, + { + "labels": [], + "name": "@ocas/output/refs", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "2TKP4RGBJ4V43", + }, + { + "labels": [], + "name": "@ocas/output/walk", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "4HG6MD3XG5H5C", + }, + { + "labels": [], + "name": "@ocas/output/list", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "CTCEXSNPWMAQQ", + }, + { + "labels": [], + "name": "@ocas/output/list-meta", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "0V41JBWK72HS3", + }, + { + "labels": [], + "name": "@ocas/output/list-schema", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "AW24Q8BKXQYTE", + }, + { + "labels": [], + "name": "@ocas/output/var-set", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "0Q5EMYK4SYSS9", + }, + { + "labels": [], + "name": "@ocas/output/var-get", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "7C75FQT98KKQD", + }, + { + "labels": [], + "name": "@ocas/output/var-delete", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "C3MYPR5RGQFZT", + }, + { + "labels": [], + "name": "@ocas/output/var-tag", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "9103EYRMM949A", + }, + { + "labels": [], + "name": "@ocas/output/var-list", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "AF0XACGXHPMC1", + }, + { + "labels": [], + "name": "@ocas/output/template-set", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "BJDHPAE4Q8TXM", + }, + { + "labels": [], + "name": "@ocas/output/template-get", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "0B0HBHZGYHR84", + }, + { + "labels": [], + "name": "@ocas/output/template-list", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "8917JQTD1R5JF", + }, + { + "labels": [], + "name": "@ocas/output/template-delete", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "BY7BGZJND3N7R", + }, + { + "labels": [], + "name": "@ocas/output/gc", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "7KHZTY010988K", + }, { "labels": [], "name": "myapp/config", diff --git a/packages/core/src/bootstrap.test.ts b/packages/core/src/bootstrap.test.ts index 25be507..0a0fcf0 100644 --- a/packages/core/src/bootstrap.test.ts +++ b/packages/core/src/bootstrap.test.ts @@ -32,23 +32,26 @@ const OUTPUT_ALIASES = [ // ────────────────────────────────────────────────────────────────────────────── describe("bootstrap - Built-in Schemas", () => { - test("should return map of 26 built-in schema aliases to hashes", async () => { + test("should return map of 29 built-in schema aliases to hashes", async () => { const store = createMemoryStore(); const builtinSchemas = await bootstrap(store); - // Should return object with 6 primitive + 20 output aliases = 26 + // Should return object with 9 primitive + 20 output aliases = 29 expect(builtinSchemas).toHaveProperty("@ocas/schema"); expect(builtinSchemas).toHaveProperty("@ocas/string"); expect(builtinSchemas).toHaveProperty("@ocas/number"); + expect(builtinSchemas).toHaveProperty("@ocas/integer"); + expect(builtinSchemas).toHaveProperty("@ocas/boolean"); + expect(builtinSchemas).toHaveProperty("@ocas/bool"); expect(builtinSchemas).toHaveProperty("@ocas/object"); expect(builtinSchemas).toHaveProperty("@ocas/array"); - expect(builtinSchemas).toHaveProperty("@ocas/bool"); + expect(builtinSchemas).toHaveProperty("@ocas/null"); for (const alias of OUTPUT_ALIASES) { expect(builtinSchemas).toHaveProperty(alias); } - expect(Object.keys(builtinSchemas)).toHaveLength(26); + expect(Object.keys(builtinSchemas)).toHaveLength(29); // All values should be valid hashes for (const [_alias, hash] of Object.entries(builtinSchemas)) { diff --git a/packages/core/src/bootstrap.ts b/packages/core/src/bootstrap.ts index 74f538a..f8cd298 100644 --- a/packages/core/src/bootstrap.ts +++ b/packages/core/src/bootstrap.ts @@ -3,6 +3,7 @@ import { isBootstrapCapableStore, } from "./bootstrap-capable.js"; import type { Hash, Store } from "./types.js"; +import type { VariableStore } from "./variable-store.js"; const JSON_SCHEMA_TYPES = [ "string", @@ -282,11 +283,18 @@ const OUTPUT_SCHEMAS: ReadonlyArray< /** * Write the meta-schema seed node into the store and register built-in schemas. - * The returned object contains aliases for the meta-schema, 5 primitive schemas, - * and 18 @ocas/output/* schemas (24 total). + * The returned object contains aliases for the meta-schema, primitive schemas, + * and @ocas/output/* schemas. * Idempotent: calling bootstrap multiple times returns the same hashes. + * + * If a varStore is provided, all aliases are also written to it via + * varStore.set(name, hash). This bypasses @ocas/ namespace protection + * (protection is enforced only at the CLI layer). */ -export async function bootstrap(store: Store): Promise> { +export async function bootstrap( + store: Store, + varStore?: VariableStore, +): Promise> { if (!isBootstrapCapableStore(store)) { throw new Error("Store does not support bootstrap"); } @@ -297,23 +305,37 @@ export async function bootstrap(store: Store): Promise> { // 2. Register built-in primitive schemas directly (without putSchema to avoid recursion) const stringHash = await store.put(metaHash, { type: "string" }); const numberHash = await store.put(metaHash, { type: "number" }); + const integerHash = await store.put(metaHash, { type: "integer" }); + const boolHash = await store.put(metaHash, { type: "boolean" }); const objectHash = await store.put(metaHash, { type: "object" }); const arrayHash = await store.put(metaHash, { type: "array" }); - const boolHash = await store.put(metaHash, { type: "boolean" }); + const nullHash = await store.put(metaHash, { type: "null" }); // 3. Register @ocas/output/* schemas const aliases: Record = { "@ocas/schema": metaHash, "@ocas/string": stringHash, "@ocas/number": numberHash, + "@ocas/integer": integerHash, + "@ocas/boolean": boolHash, + "@ocas/bool": boolHash, "@ocas/object": objectHash, "@ocas/array": arrayHash, - "@ocas/bool": boolHash, + "@ocas/null": nullHash, }; for (const [alias, schema] of OUTPUT_SCHEMAS) { aliases[alias] = await store.put(metaHash, schema); } + // 4. Write all aliases to varStore (when provided). + // Idempotent: VariableStore.set is an upsert. Bypasses @ocas/ namespace + // protection — protection is only enforced on the CLI `var set` command. + if (varStore !== undefined) { + for (const [name, hash] of Object.entries(aliases)) { + varStore.set(name, hash); + } + } + return aliases; } diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index d94bbc5..bfc39a7 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -271,9 +271,12 @@ describe("bootstrap", () => { expect(builtinSchemas).toHaveProperty("@ocas/schema"); expect(builtinSchemas).toHaveProperty("@ocas/string"); expect(builtinSchemas).toHaveProperty("@ocas/number"); + expect(builtinSchemas).toHaveProperty("@ocas/integer"); + expect(builtinSchemas).toHaveProperty("@ocas/boolean"); + expect(builtinSchemas).toHaveProperty("@ocas/bool"); expect(builtinSchemas).toHaveProperty("@ocas/object"); expect(builtinSchemas).toHaveProperty("@ocas/array"); - expect(builtinSchemas).toHaveProperty("@ocas/bool"); + expect(builtinSchemas).toHaveProperty("@ocas/null"); // All values should be valid hashes for (const hash of Object.values(builtinSchemas)) { @@ -281,7 +284,7 @@ describe("bootstrap", () => { expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); } - expect(Object.keys(builtinSchemas)).toHaveLength(26); + expect(Object.keys(builtinSchemas)).toHaveLength(29); }); test("meta-schema node is stored and retrievable", async () => { @@ -318,7 +321,7 @@ describe("bootstrap", () => { const h2 = await bootstrap(store); expect(h1).toEqual(h2); - // All 26 built-in schemas should be typed by the meta-schema - expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(26); + // All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 20 outputs) + expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(28); }); }); diff --git a/packages/fs/src/store.test.ts b/packages/fs/src/store.test.ts index 0fbb01e..08f93b9 100644 --- a/packages/fs/src/store.test.ts +++ b/packages/fs/src/store.test.ts @@ -67,7 +67,7 @@ describe("createFsStore – init and bootstrap", () => { const h2 = await bootstrap(store); expect(h1).toEqual(h2); - expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(26); + expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(28); }); }); diff --git a/packages/fs/src/store.ts b/packages/fs/src/store.ts index 30c547b..e14edb1 100644 --- a/packages/fs/src/store.ts +++ b/packages/fs/src/store.ts @@ -10,7 +10,12 @@ import { writeFileSync, } from "node:fs"; import { join } from "node:path"; -import type { BootstrapCapableStore, CasNode, Hash } from "@ocas/core"; +import type { + BootstrapCapableStore, + CasNode, + Hash, + VariableStore, +} from "@ocas/core"; import { BOOTSTRAP_STORE, @@ -310,10 +315,15 @@ export function createFsStore(dir: string): BootstrapCapableStore { * 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): Promise { +export async function openStore( + dir: string, + varStore?: VariableStore, +): Promise { // Create directory if it doesn't exist try { mkdirSync(dir, { recursive: true }); @@ -323,7 +333,7 @@ export async function openStore(dir: string): Promise { if (nodeError.code === "EACCES") { throw new Error(`Permission denied: cannot access store at ${dir}`); } - if (nodeError.code === "ENOTDIR") { + if (nodeError.code === "ENOTDIR" || nodeError.code === "EEXIST") { throw new Error(`Path exists but is not a directory: ${dir}`); } } @@ -350,7 +360,7 @@ export async function openStore(dir: string): Promise { const store = createFsStore(dir); // Bootstrap (idempotent) - await bootstrap(store); + await bootstrap(store, varStore); return store; }