feat: bootstrap writes to varStore #21

Merged
xiaomo merged 1 commits from feat/17-bootstrap-varstore into main 2026-06-01 10:56:46 +00:00
7 changed files with 325 additions and 46 deletions
+66 -28
View File
@@ -156,31 +156,37 @@ async function readStdinJson(): Promise<unknown> {
/**
* 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<Store> {
async function openStore(varStore?: VariableStore): Promise<Store> {
const fullPath = resolve(storePath);
return await openFsStore(fullPath);
return await openFsStore(fullPath, varStore);
}
async function openVarStore(): Promise<VariableStore> {
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<Hash> {
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<void> {
// 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<string, unknown>);
@@ -283,7 +288,10 @@ async function cmdHas(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: ocas has <hash>");
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<void> {
@@ -345,7 +353,10 @@ async function cmdWalk(args: string[]): Promise<void> {
}
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<void> {
: 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
schemaHash,
contentHash,
}),
store);
store,
);
} catch (e) {
if (e instanceof CasNodeNotFoundError) {
die(`Error: ${e.message}`);
@@ -775,7 +805,8 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
"@ocas/output/template-get",
node.payload as string,
),
store);
store,
);
} finally {
varStore.close();
}
@@ -797,7 +828,10 @@ async function cmdTemplateList(_args: string[]): Promise<void> {
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<void> {
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<void> {
async function cmdListSchema(_args: string[]): Promise<void> {
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}`);
}
}
@@ -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",
+7 -4
View File
@@ -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)) {
+27 -5
View File
@@ -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<Record<string, Hash>> {
export async function bootstrap(
store: Store,
varStore?: VariableStore,
): Promise<Record<string, Hash>> {
if (!isBootstrapCapableStore(store)) {
throw new Error("Store does not support bootstrap");
}
@@ -297,23 +305,37 @@ export async function bootstrap(store: Store): Promise<Record<string, Hash>> {
// 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<string, Hash> = {
"@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;
}
+7 -4
View File
@@ -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);
});
});
+1 -1
View File
@@ -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);
});
});
+14 -4
View File
@@ -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<BootstrapCapableStore> {
export async function openStore(
dir: string,
varStore?: VariableStore,
): Promise<BootstrapCapableStore> {
// Create directory if it doesn't exist
try {
mkdirSync(dir, { recursive: true });
@@ -323,7 +333,7 @@ export async function openStore(dir: string): Promise<BootstrapCapableStore> {
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<BootstrapCapableStore> {
const store = createFsStore(dir);
// Bootstrap (idempotent)
await bootstrap(store);
await bootstrap(store, varStore);
return store;
}