feat: add list-meta and list-schema commands with persistent meta index #91

Merged
xiaoju merged 2 commits from fix/90-list-meta-schema into main 2026-06-01 05:48:07 +00:00
20 changed files with 645 additions and 30 deletions
+22
View File
@@ -823,6 +823,18 @@ async function cmdList(_args: string[]): Promise<void> {
out(await wrapEnvelope(store, "@output/list", hashes));
}
async function cmdListMeta(_args: string[]): Promise<void> {
const store = await openStore();
const hashes = store.listMeta();
out(await wrapEnvelope(store, "@output/list-meta", hashes));
}
async function cmdListSchema(_args: string[]): Promise<void> {
const store = await openStore();
const hashes = store.listSchemas();
out(await wrapEnvelope(store, "@output/list-schema", hashes));
}
function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
@@ -842,6 +854,8 @@ Commands:
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
list-meta List meta-schema hashes (value=string[]) (@output/list-meta)
list-schema List all schema hashes (value=string[]) (@output/list-schema)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
@@ -912,6 +926,14 @@ switch (cmd) {
await cmdList(rest);
break;
case "list-meta":
await cmdListMeta(rest);
break;
case "list-schema":
await cmdListSchema(rest);
break;
case "var": {
const [sub, ...subRest] = rest;
switch (sub) {
@@ -24,6 +24,8 @@ Commands:
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
list-meta List meta-schema hashes (value=string[]) (@output/list-meta)
list-schema List all schema hashes (value=string[]) (@output/list-schema)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
+1 -5
View File
@@ -1,9 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
mkdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
+12 -3
View File
@@ -75,7 +75,10 @@ describe("Phase 7: Edge Cases", () => {
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
@@ -191,7 +194,10 @@ describe("Phase 3: Variable System", () => {
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
@@ -377,7 +383,10 @@ describe("Phase 4: Template System", () => {
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
});
afterAll(() => {
+14 -2
View File
@@ -28,7 +28,10 @@ beforeAll(async () => {
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
@@ -78,7 +81,16 @@ describe("Phase 6: GC", () => {
expect(gcExit).toBe(0);
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, "render", "--pipe"],
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"render",
"--pipe",
],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(gcOut);
+17 -4
View File
@@ -1,13 +1,26 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
export { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync };
export { tmpdir };
export { join, resolve };
export {
join,
mkdirSync,
mkdtempSync,
readFileSync,
resolve,
rmSync,
tmpdir,
writeFileSync,
};
export const entrypoint = resolve(import.meta.dir, "../src/index.ts");
export const pkgPath = resolve(import.meta.dir, "../package.json");
@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BOOTSTRAP_STORE } from "@uncaged/json-cas";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
import { envValue, runCli } from "./helpers.js";
let storePath: string;
beforeEach(() => {
storePath = mkdtempSync(join(tmpdir(), "json-cas-list-meta-schema-"));
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
rmSync(storePath, { recursive: true, force: true });
});
describe("list-meta CLI command", () => {
test("E1. list-meta on bootstrapped store contains exactly the meta-schema hash", async () => {
// First, get @schema hash by calling has on it (also triggers bootstrap)
const { stdout: hashOut, exitCode: hashCode } = await runCli(
["hash", "@schema", "--pipe"],
storePath,
);
// ensure bootstrap by running a no-op command:
void hashOut;
void hashCode;
// Bootstrap fully via 'list --type @schema'
const { stdout: schemaListOut } = await runCli(
["list", "--type", "@schema"],
storePath,
);
const schemaList = envValue(schemaListOut) as string[];
expect(Array.isArray(schemaList)).toBe(true);
const { stdout, stderr, exitCode } = await runCli(["list-meta"], storePath);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(Array.isArray(parsed.value)).toBe(true);
expect(parsed.value).toHaveLength(1);
expect(parsed.value[0]).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("E1. --json flag yields compact JSON", async () => {
const { stdout, exitCode } = await runCli(
["--json", "list-meta"],
storePath,
);
expect(exitCode).toBe(0);
// compact = no newlines/spaces between fields
expect(stdout.trim()).not.toContain("\n ");
});
});
describe("list-schema CLI command", () => {
test("E2. list-schema on bootstrapped store includes meta-schema and built-ins", async () => {
const { stdout, stderr, exitCode } = await runCli(
["list-schema"],
storePath,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const parsed = JSON.parse(stdout) as { type: string; value: string[] };
expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(Array.isArray(parsed.value)).toBe(true);
// meta + 5 primitive + 20 output = 26
expect(parsed.value.length).toBeGreaterThanOrEqual(6);
});
});
describe("usage help", () => {
test("E3. printing usage includes list-meta and list-schema lines", async () => {
const { stdout, exitCode } = await runCli([], storePath);
expect(exitCode).toBe(0);
expect(stdout).toContain("list-meta");
expect(stdout).toContain("list-schema");
});
});
describe("F1. output schemas registered", () => {
test("@output/list-meta and @output/list-schema schemas exist", async () => {
const { stdout, exitCode } = await runCli(["list-meta"], storePath);
expect(exitCode).toBe(0);
const parsed = JSON.parse(stdout) as { type: string };
// type hash references the @output/list-meta schema, must be retrievable
const { stdout: getOut, exitCode: getCode } = await runCli(
["get", parsed.type],
storePath,
);
expect(getCode).toBe(0);
const node = envValue(getOut) as {
payload: { title?: string };
};
expect(node.payload.title).toBe("ucas list-meta result");
const { stdout: schemaOut } = await runCli(["list-schema"], storePath);
const schemaParsed = JSON.parse(schemaOut) as { type: string };
const { stdout: getSchOut, exitCode: getSchCode } = await runCli(
["get", schemaParsed.type],
storePath,
);
expect(getSchCode).toBe(0);
const schNode = envValue(getSchOut) as {
payload: { title?: string };
};
expect(schNode.payload.title).toBe("ucas list-schema result");
});
});
describe("E4. list-schema vs list --type with multiple meta-schema versions", () => {
test("list-schema includes schemas typed by older meta-schemas; list --type @schema does not", async () => {
// Set up two distinct meta-schemas via the library
const store = await openFsStore(storePath);
const m1 = await store[BOOTSTRAP_STORE]({
type: "object",
title: "meta-v1",
});
const m2 = await store[BOOTSTRAP_STORE]({
type: "object",
title: "meta-v2",
});
// schema typed by older meta M1
const sM1 = await store.put(m1, { type: "string" });
expect(m1).not.toBe(m2);
// CLI: list-schema must include sM1
const { stdout: lsOut, exitCode: lsCode } = await runCli(
["list-schema"],
storePath,
);
expect(lsCode).toBe(0);
const lsValue = envValue(lsOut) as string[];
expect(lsValue).toContain(sM1);
expect(lsValue).toContain(m1);
expect(lsValue).toContain(m2);
// CLI: list --type <M2 hash> must NOT include sM1
const { stdout: ltOut, exitCode: ltCode } = await runCli(
["list", "--type", m2],
storePath,
);
expect(ltCode).toBe(0);
const ltValue = envValue(ltOut) as string[];
expect(ltValue).not.toContain(sM1);
// list-schema.value.length > list --type <newest>.value.length
expect(lsValue.length).toBeGreaterThan(ltValue.length);
});
});
+4 -2
View File
@@ -28,7 +28,10 @@ beforeAll(async () => {
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
@@ -143,7 +146,6 @@ describe("Phase 8: Pipe Composition", () => {
expect(exitCode).toBe(0);
expect(stdout).toBe("Person: Carol (25)");
});
});
// ---- Phase 9: Put/Hash Pipe Input ----
+4 -1
View File
@@ -104,7 +104,10 @@ describe("Phase 5: Render", () => {
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
@@ -579,12 +579,25 @@ describe("Phase 2: Schema Validation", () => {
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, "put", typeHash, nodeFile],
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"put",
typeHash,
nodeFile,
],
{ stdout: "pipe", stderr: "pipe" },
);
await proc.exited;
@@ -600,7 +613,17 @@ describe("Phase 2: Schema Validation", () => {
const badFile = join(tmpStore, "bad-node.json");
writeFileSync(badFile, JSON.stringify({ name: 123 }));
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, "put", typeHash, badFile],
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"put",
typeHash,
badFile,
],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
@@ -615,7 +638,17 @@ describe("Phase 2: Schema Validation", () => {
test("2.3 put against non-existent schema hash fails", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, "put", "AAAAAAAAAAAAA", nodeFile],
[
"bun",
entrypoint,
"--store",
tmpStore,
"--var-db",
varDbPath,
"put",
"AAAAAAAAAAAAA",
nodeFile,
],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
@@ -28,7 +28,10 @@ beforeAll(async () => {
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
const { putSchema } = await import("@uncaged/json-cas");
const store = await openFsStore(tmpStore);
typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8")));
typeHash = await putSchema(
store,
JSON.parse(readFileSync(schemaFile, "utf-8")),
);
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
+130 -1
View File
@@ -3,6 +3,7 @@ import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
@@ -10,6 +11,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasNode } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
bootstrap,
computeHash,
computeSelfHash,
@@ -65,7 +67,7 @@ describe("createFsStore – init and bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(26);
});
});
@@ -429,3 +431,130 @@ describe("openStore – async with auto-bootstrap", () => {
expect(store2.listByType(typeHash)).toHaveLength(1);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// listMeta and listSchemas (FS persistence)
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – listMeta and listSchemas", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "json-cas-fs-meta-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("C1. _index/_meta exists and contains hash after self-referencing put", async () => {
const store = createFsStore(dir);
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
const metaPath = join(dir, "_index", "_meta");
expect(existsSync(metaPath)).toBe(true);
const content = readFileSync(metaPath, "utf8");
expect(content).toBe(`${hash}\n`);
});
test("C2. multiple meta puts append, no duplicates", async () => {
const store = createFsStore(dir);
const h1 = await store[BOOTSTRAP_STORE]({ type: "object", v: 1 });
const h2 = await store[BOOTSTRAP_STORE]({ type: "object", v: 2 });
// re-put first (idempotent)
await store[BOOTSTRAP_STORE]({ type: "object", v: 1 });
const content = readFileSync(join(dir, "_index", "_meta"), "utf8");
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines).toHaveLength(2);
expect(lines).toContain(h1);
expect(lines).toContain(h2);
});
test("C3. reload from disk preserves listMeta", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "a" });
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "b" });
const store2 = createFsStore(dir);
const meta = store2.listMeta();
expect(meta).toContain(h1);
expect(meta).toContain(h2);
expect(meta).toHaveLength(2);
});
test("C4. migrates from existing nodes when _meta is missing", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "mig" });
const t = await computeSelfHash({ name: "regular" });
await store1.put(h1, { type: "string" });
// Remove _index/_meta but keep _index/* and .bin files
const metaPath = join(dir, "_index", "_meta");
rmSync(metaPath, { force: true });
expect(existsSync(metaPath)).toBe(false);
const store2 = createFsStore(dir);
expect(store2.listMeta()).toContain(h1);
expect(existsSync(metaPath)).toBe(true);
const content = readFileSync(metaPath, "utf8");
expect(content).toContain(h1);
// unrelated type hash not in meta
expect(store2.listMeta()).not.toContain(t);
});
test("C5. existing _meta is not overwritten", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "keep" });
const metaPath = join(dir, "_index", "_meta");
const before = readFileSync(metaPath, "utf8");
// Reopen and confirm content unchanged
const _store2 = createFsStore(dir);
const after = readFileSync(metaPath, "utf8");
expect(after).toBe(before);
expect(after).toContain(h1);
});
test("C6. listSchemas returns union of typeIndex[m] for m in metaSet", async () => {
const store = createFsStore(dir);
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s1 = await store.put(m, { type: "string" });
const s2 = await store.put(m, { type: "number" });
const s3 = await store.put(m, { type: "array" });
const schemas = store.listSchemas();
expect(schemas).toHaveLength(4);
expect(schemas).toContain(m);
expect(schemas).toContain(s1);
expect(schemas).toContain(s2);
expect(schemas).toContain(s3);
});
test("C7. delete persists removal from _meta", async () => {
const store1 = createFsStore(dir);
const h1 = await store1[BOOTSTRAP_STORE]({ type: "object", v: 1 });
const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: 2 });
store1.delete(h1);
const metaPath = join(dir, "_index", "_meta");
const content = readFileSync(metaPath, "utf8");
expect(content).not.toContain(h1);
expect(content).toContain(h2);
const store2 = createFsStore(dir);
expect(store2.listMeta()).not.toContain(h1);
expect(store2.listMeta()).toContain(h2);
});
test("C8. fresh store with no self-ref puts has empty listMeta", () => {
const store = createFsStore(dir);
expect(store.listMeta()).toEqual([]);
// _meta may be absent; that's fine
const metaPath = join(dir, "_index", "_meta");
if (existsSync(metaPath)) {
const content = readFileSync(metaPath, "utf8");
expect(content).toBe("");
}
});
});
+79
View File
@@ -22,6 +22,7 @@ import {
import { decode } from "cborg";
const INDEX_DIR = "_index";
const META_FILE = "_meta";
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
let entries: string[];
@@ -100,6 +101,61 @@ function loadOrMigrateTypeIndex(
return loadTypeIndex(indexDir);
}
function loadOrMigrateMetaSet(
dir: string,
data: Map<Hash, CasNode>,
): Set<Hash> {
const indexDir = join(dir, INDEX_DIR);
const metaPath = join(indexDir, META_FILE);
if (existsSync(metaPath)) {
try {
const content = readFileSync(metaPath, "utf8");
return new Set(parseIndexFile(content));
} catch {
return new Set();
}
}
// Migration: scan loaded nodes for self-referencing nodes (type === hash)
const metaSet = new Set<Hash>();
for (const [hash, node] of data) {
if (node.type === hash) {
metaSet.add(hash);
}
}
if (metaSet.size > 0) {
mkdirSync(indexDir, { recursive: true });
const body = `${[...metaSet].join("\n")}\n`;
writeFileSync(metaPath, body, "utf8");
}
return metaSet;
}
function appendToMetaSet(
indexDir: string,
metaSet: Set<Hash>,
hash: Hash,
): void {
if (metaSet.has(hash)) return;
metaSet.add(hash);
mkdirSync(indexDir, { recursive: true });
appendFileSync(join(indexDir, META_FILE), `${hash}\n`, "utf8");
}
function rewriteMetaSet(indexDir: string, metaSet: Set<Hash>): void {
const metaPath = join(indexDir, META_FILE);
if (metaSet.size === 0) {
try {
unlinkSync(metaPath);
} catch {
// ignore
}
return;
}
mkdirSync(indexDir, { recursive: true });
const body = `${[...metaSet].join("\n")}\n`;
writeFileSync(metaPath, body, "utf8");
}
function appendToTypeIndex(
indexDir: string,
typeIndex: Map<Hash, Hash[]>,
@@ -118,6 +174,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
loadDir(dir, data);
const indexDir = join(dir, INDEX_DIR);
const typeIndex = loadOrMigrateTypeIndex(dir, data);
const metaSet = loadOrMigrateMetaSet(dir, data);
async function putSelfReferencing(payload: unknown): Promise<Hash> {
const hash = await computeSelfHash(payload);
@@ -136,6 +193,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
appendToTypeIndex(indexDir, typeIndex, hash, hash);
}
appendToMetaSet(indexDir, metaSet, hash);
return hash;
}
@@ -182,6 +240,22 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return Array.from(data.keys());
},
listMeta(): Hash[] {
return Array.from(metaSet);
},
listSchemas(): Hash[] {
const result = new Set<Hash>();
for (const meta of metaSet) {
result.add(meta);
const list = typeIndex.get(meta);
if (list) {
for (const h of list) result.add(h);
}
}
return Array.from(result);
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
@@ -213,6 +287,11 @@ export function createFsStore(dir: string): BootstrapCapableStore {
writeFileSync(join(indexDir, node.type), body, "utf8");
}
}
// Remove from meta set if applicable
if (metaSet.has(hash)) {
metaSet.delete(hash);
rewriteMetaSet(indexDir, metaSet);
}
}
},
+25 -3
View File
@@ -13,6 +13,8 @@ const OUTPUT_ALIASES = [
"@output/refs",
"@output/walk",
"@output/list",
"@output/list-meta",
"@output/list-schema",
"@output/var-set",
"@output/var-get",
"@output/var-delete",
@@ -30,11 +32,11 @@ const OUTPUT_ALIASES = [
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of 24 built-in schema aliases to hashes", async () => {
test("should return map of 26 built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
// Should return object with 6 primitive + 18 output aliases = 24
// Should return object with 6 primitive + 20 output aliases = 26
expect(builtinSchemas).toHaveProperty("@schema");
expect(builtinSchemas).toHaveProperty("@string");
expect(builtinSchemas).toHaveProperty("@number");
@@ -46,7 +48,7 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(24);
expect(Object.keys(builtinSchemas)).toHaveLength(26);
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
@@ -316,3 +318,23 @@ describe("bootstrap - @output/* Schemas", () => {
expect(uniqueHashes.size).toBe(OUTPUT_ALIASES.length);
});
});
describe("bootstrap - meta and schemas indexes (D1)", () => {
test("listMeta contains the bootstrap meta-schema hash", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const metaHash = aliases["@schema"];
expect(store.listMeta()).toContain(metaHash as string);
});
test("listSchemas contains meta-schema and all built-in schemas", async () => {
const store = createMemoryStore();
const aliases = await bootstrap(store);
const schemas = store.listSchemas();
for (const [, hash] of Object.entries(aliases)) {
expect(schemas).toContain(hash);
}
expect(schemas.length).toBeGreaterThanOrEqual(6);
});
});
+16
View File
@@ -172,6 +172,22 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
title: "ucas list result",
},
],
[
"@output/list-meta",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas list-meta result",
},
],
[
"@output/list-schema",
{
type: "array",
items: { type: "string", format: "cas_ref" },
title: "ucas list-schema result",
},
],
[
"@output/var-set",
{
+4 -4
View File
@@ -264,7 +264,7 @@ describe("bootstrap", () => {
);
});
test("returns a map with 24 built-in schema aliases", async () => {
test("returns a map with 26 built-in schema aliases", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
@@ -281,7 +281,7 @@ describe("bootstrap", () => {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(Object.keys(builtinSchemas)).toHaveLength(24);
expect(Object.keys(builtinSchemas)).toHaveLength(26);
});
test("meta-schema node is stored and retrievable", async () => {
@@ -318,7 +318,7 @@ describe("bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
// All 24 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
// All 26 built-in schemas should be typed by the meta-schema
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(26);
});
});
+8
View File
@@ -31,6 +31,14 @@ export class MemStore implements BootstrapCapableStore {
return this.#inner.listAll();
}
listMeta(): Hash[] {
return this.#inner.listMeta();
}
listSchemas(): Hash[] {
return this.#inner.listSchemas();
}
delete(hash: Hash): void {
this.#inner.delete(hash);
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test";
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
import { createMemoryStore } from "./store.js";
describe("createMemoryStore – meta and schema indexes", () => {
test("B1. listMeta and listSchemas are empty on a fresh store", () => {
const store = createMemoryStore();
expect(store.listMeta()).toEqual([]);
expect(store.listSchemas()).toEqual([]);
});
test("B2. self-referencing put adds hash to metaSet and listSchemas", async () => {
const store = createMemoryStore();
const hash = await store[BOOTSTRAP_STORE]({ type: "object" });
expect(store.listMeta()).toContain(hash);
expect(store.listSchemas()).toContain(hash);
});
test("B3. regular put does not add hash to metaSet", async () => {
const store = createMemoryStore();
const metaHash = await store[BOOTSTRAP_STORE]({ type: "object" });
const schemaHash = await store.put(metaHash, { type: "string" });
expect(store.listMeta()).not.toContain(schemaHash);
expect(store.listMeta()).toContain(metaHash);
});
test("B4. schema typed by meta-schema appears in listSchemas", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s = await store.put(m, { type: "string" });
const schemas = store.listSchemas();
expect(schemas).toContain(m);
expect(schemas).toContain(s);
const meta = store.listMeta();
expect(meta).toContain(m);
expect(meta).not.toContain(s);
});
test("B5. multiple meta-schemas (versioning)", async () => {
const store = createMemoryStore();
const m1 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v1" });
const m2 = await store[BOOTSTRAP_STORE]({ type: "object", title: "v2" });
const s1 = await store.put(m1, { type: "string" });
const s2 = await store.put(m2, { type: "number" });
const meta = store.listMeta();
expect(meta).toContain(m1);
expect(meta).toContain(m2);
expect(meta).toHaveLength(2);
const schemas = store.listSchemas();
expect(schemas).toContain(m1);
expect(schemas).toContain(m2);
expect(schemas).toContain(s1);
expect(schemas).toContain(s2);
});
test("B6. idempotent self-referencing put does not duplicate", async () => {
const store = createMemoryStore();
const payload = { type: "object", title: "dup" };
const h1 = await store[BOOTSTRAP_STORE](payload);
const h2 = await store[BOOTSTRAP_STORE](payload);
expect(h1).toBe(h2);
const meta = store.listMeta();
const occurrences = meta.filter((h) => h === h1).length;
expect(occurrences).toBe(1);
});
test("B7. delete removes hash from metaSet and listSchemas", async () => {
const store = createMemoryStore();
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
const s = await store.put(m, { type: "string" });
expect(store.listMeta()).toContain(m);
expect(store.listSchemas()).toContain(s);
store.delete(m);
expect(store.listMeta()).not.toContain(m);
// schemas typed by deleted meta no longer surface
expect(store.listSchemas()).not.toContain(s);
expect(store.listSchemas()).not.toContain(m);
});
});
+19
View File
@@ -8,6 +8,7 @@ import type { CasNode, Hash } from "./types.js";
export function createMemoryStore(): BootstrapCapableStore {
const data = new Map<Hash, CasNode>();
const byType = new Map<Hash, Set<Hash>>();
const metaSet = new Set<Hash>();
function indexHash(type: Hash, hash: Hash): void {
let set = byType.get(type);
@@ -24,6 +25,7 @@ export function createMemoryStore(): BootstrapCapableStore {
data.set(hash, { type: hash, payload, timestamp: Date.now() });
indexHash(hash, hash);
}
metaSet.add(hash);
return hash;
}
@@ -56,6 +58,22 @@ export function createMemoryStore(): BootstrapCapableStore {
return Array.from(data.keys());
},
listMeta(): Hash[] {
return Array.from(metaSet);
},
listSchemas(): Hash[] {
const result = new Set<Hash>();
for (const meta of metaSet) {
result.add(meta);
const set = byType.get(meta);
if (set) {
for (const h of set) result.add(h);
}
}
return Array.from(result);
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
@@ -68,6 +86,7 @@ export function createMemoryStore(): BootstrapCapableStore {
byType.delete(node.type);
}
}
metaSet.delete(hash);
}
},
+2
View File
@@ -25,5 +25,7 @@ export type Store = {
has(hash: Hash): boolean;
listByType(typeHash: Hash): Hash[];
listAll(): Hash[];
listMeta(): Hash[];
listSchemas(): Hash[];
delete(hash: Hash): void;
};