diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 200c120..411b1de 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -823,6 +823,18 @@ async function cmdList(_args: string[]): Promise { out(await wrapEnvelope(store, "@output/list", hashes)); } +async function cmdListMeta(_args: string[]): Promise { + const store = await openStore(); + const hashes = store.listMeta(); + out(await wrapEnvelope(store, "@output/list-meta", hashes)); +} + +async function cmdListSchema(_args: string[]): Promise { + 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 ] [--json] [args] @@ -842,6 +854,8 @@ Commands: render [options] Render node as text with resolution decay (raw output) render --pipe/-p [options] Render { type, value } from stdin (raw output) list --type 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 [--tag ...] Create/update a variable (@output/var-set) var get --schema Get a variable by name + schema (@output/var-get) var delete [--schema ] 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) { diff --git a/packages/cli-json-cas/tests/__snapshots__/edge-cases.test.ts.snap b/packages/cli-json-cas/tests/__snapshots__/edge-cases.test.ts.snap index e9c909f..429e320 100644 --- a/packages/cli-json-cas/tests/__snapshots__/edge-cases.test.ts.snap +++ b/packages/cli-json-cas/tests/__snapshots__/edge-cases.test.ts.snap @@ -24,6 +24,8 @@ Commands: render [options] Render node as text with resolution decay (raw output) render --pipe/-p [options] Render { type, value } from stdin (raw output) list --type 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 [--tag ...] Create/update a variable (@output/var-set) var get --schema Get a variable by name + schema (@output/var-get) var delete [--schema ] Delete variable(s) (@output/var-delete) diff --git a/packages/cli-json-cas/tests/alias.test.ts b/packages/cli-json-cas/tests/alias.test.ts index 9d9b41a..a52fd66 100644 --- a/packages/cli-json-cas/tests/alias.test.ts +++ b/packages/cli-json-cas/tests/alias.test.ts @@ -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"; diff --git a/packages/cli-json-cas/tests/edge-cases.test.ts b/packages/cli-json-cas/tests/edge-cases.test.ts index 3d0a7dc..32b29c9 100644 --- a/packages/cli-json-cas/tests/edge-cases.test.ts +++ b/packages/cli-json-cas/tests/edge-cases.test.ts @@ -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(() => { diff --git a/packages/cli-json-cas/tests/gc.test.ts b/packages/cli-json-cas/tests/gc.test.ts index 18c1965..7d49751 100644 --- a/packages/cli-json-cas/tests/gc.test.ts +++ b/packages/cli-json-cas/tests/gc.test.ts @@ -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); diff --git a/packages/cli-json-cas/tests/helpers.ts b/packages/cli-json-cas/tests/helpers.ts index f707a5b..0d9a380 100644 --- a/packages/cli-json-cas/tests/helpers.ts +++ b/packages/cli-json-cas/tests/helpers.ts @@ -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"); diff --git a/packages/cli-json-cas/tests/list-meta-schema.test.ts b/packages/cli-json-cas/tests/list-meta-schema.test.ts new file mode 100644 index 0000000..9636a6b --- /dev/null +++ b/packages/cli-json-cas/tests/list-meta-schema.test.ts @@ -0,0 +1,113 @@ +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 { 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"); + }); +}); diff --git a/packages/cli-json-cas/tests/pipe.test.ts b/packages/cli-json-cas/tests/pipe.test.ts index d3e8909..b124ae8 100644 --- a/packages/cli-json-cas/tests/pipe.test.ts +++ b/packages/cli-json-cas/tests/pipe.test.ts @@ -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 ---- diff --git a/packages/cli-json-cas/tests/render.test.ts b/packages/cli-json-cas/tests/render.test.ts index c0d4135..d2e6a16 100644 --- a/packages/cli-json-cas/tests/render.test.ts +++ b/packages/cli-json-cas/tests/render.test.ts @@ -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 })); diff --git a/packages/cli-json-cas/tests/schema-validation.test.ts b/packages/cli-json-cas/tests/schema-validation.test.ts index acf087c..88275be 100644 --- a/packages/cli-json-cas/tests/schema-validation.test.ts +++ b/packages/cli-json-cas/tests/schema-validation.test.ts @@ -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; diff --git a/packages/cli-json-cas/tests/verify-refs-walk.test.ts b/packages/cli-json-cas/tests/verify-refs-walk.test.ts index d2e89e0..21e13e4 100644 --- a/packages/cli-json-cas/tests/verify-refs-walk.test.ts +++ b/packages/cli-json-cas/tests/verify-refs-walk.test.ts @@ -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 })); diff --git a/packages/json-cas-fs/src/store.test.ts b/packages/json-cas-fs/src/store.test.ts index 7f157f7..ecb665b 100644 --- a/packages/json-cas-fs/src/store.test.ts +++ b/packages/json-cas-fs/src/store.test.ts @@ -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(""); + } + }); +}); diff --git a/packages/json-cas-fs/src/store.ts b/packages/json-cas-fs/src/store.ts index 2d46e9f..de2da3d 100644 --- a/packages/json-cas-fs/src/store.ts +++ b/packages/json-cas-fs/src/store.ts @@ -22,6 +22,7 @@ import { import { decode } from "cborg"; const INDEX_DIR = "_index"; +const META_FILE = "_meta"; function loadDir(dir: string, data: Map): void { let entries: string[]; @@ -100,6 +101,61 @@ function loadOrMigrateTypeIndex( return loadTypeIndex(indexDir); } +function loadOrMigrateMetaSet( + dir: string, + data: Map, +): Set { + 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(); + 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, +): 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): 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, @@ -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 { 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(); + 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); + } } }, diff --git a/packages/json-cas/src/bootstrap.test.ts b/packages/json-cas/src/bootstrap.test.ts index 7f328ce..5782d5a 100644 --- a/packages/json-cas/src/bootstrap.test.ts +++ b/packages/json-cas/src/bootstrap.test.ts @@ -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); + }); +}); diff --git a/packages/json-cas/src/bootstrap.ts b/packages/json-cas/src/bootstrap.ts index 53218da..f765610 100644 --- a/packages/json-cas/src/bootstrap.ts +++ b/packages/json-cas/src/bootstrap.ts @@ -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", { diff --git a/packages/json-cas/src/index.test.ts b/packages/json-cas/src/index.test.ts index 573c800..dc86584 100644 --- a/packages/json-cas/src/index.test.ts +++ b/packages/json-cas/src/index.test.ts @@ -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); }); }); diff --git a/packages/json-cas/src/mem-store.ts b/packages/json-cas/src/mem-store.ts index dca2959..8876b7e 100644 --- a/packages/json-cas/src/mem-store.ts +++ b/packages/json-cas/src/mem-store.ts @@ -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); } diff --git a/packages/json-cas/src/store.test.ts b/packages/json-cas/src/store.test.ts new file mode 100644 index 0000000..6ebbc2f --- /dev/null +++ b/packages/json-cas/src/store.test.ts @@ -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); + }); +}); diff --git a/packages/json-cas/src/store.ts b/packages/json-cas/src/store.ts index 79c81f0..4c6f9d1 100644 --- a/packages/json-cas/src/store.ts +++ b/packages/json-cas/src/store.ts @@ -8,6 +8,7 @@ import type { CasNode, Hash } from "./types.js"; export function createMemoryStore(): BootstrapCapableStore { const data = new Map(); const byType = new Map>(); + const metaSet = new Set(); 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(); + 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); } }, diff --git a/packages/json-cas/src/types.ts b/packages/json-cas/src/types.ts index b99db0a..ee88836 100644 --- a/packages/json-cas/src/types.ts +++ b/packages/json-cas/src/types.ts @@ -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; };