feat: add list-meta and list-schema commands with persistent meta index
Adds Store.listMeta() and Store.listSchemas() to expose meta-schema discovery, backed by an in-memory metaSet (memory store) and a persistent _index/_meta file (FS store). Surfaces both via new json-cas list-meta and list-schema CLI commands wrapped in @output/list-meta and @output/list-schema envelope schemas. The FS store migrates from existing nodes when _meta is absent (scanning self-referencing nodes) and preserves _meta on subsequent opens. delete() removes affected hashes from the meta index and persists the change. Closes #90 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -823,6 +823,18 @@ async function cmdList(_args: string[]): Promise<void> {
|
|||||||
out(await wrapEnvelope(store, "@output/list", hashes));
|
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 {
|
function printUsage(): void {
|
||||||
console.log(`\
|
console.log(`\
|
||||||
Usage: json-cas [--store <path>] [--json] <command> [args]
|
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 <hash> [options] Render node as text with resolution decay (raw output)
|
||||||
render --pipe/-p [options] Render { type, value } from stdin (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 --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 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 get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
|
||||||
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
|
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
|
||||||
@@ -912,6 +926,14 @@ switch (cmd) {
|
|||||||
await cmdList(rest);
|
await cmdList(rest);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "list-meta":
|
||||||
|
await cmdListMeta(rest);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "list-schema":
|
||||||
|
await cmdListSchema(rest);
|
||||||
|
break;
|
||||||
|
|
||||||
case "var": {
|
case "var": {
|
||||||
const [sub, ...subRest] = rest;
|
const [sub, ...subRest] = rest;
|
||||||
switch (sub) {
|
switch (sub) {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ Commands:
|
|||||||
render <hash> [options] Render node as text with resolution decay (raw output)
|
render <hash> [options] Render node as text with resolution decay (raw output)
|
||||||
render --pipe/-p [options] Render { type, value } from stdin (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 --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 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 get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
|
||||||
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
|
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
import {
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
mkdirSync,
|
|
||||||
rmSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,10 @@ describe("Phase 7: Edge Cases", () => {
|
|||||||
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
const { putSchema } = await import("@uncaged/json-cas");
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
const store = await openFsStore(tmpStore);
|
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");
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
const { putSchema } = await import("@uncaged/json-cas");
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
const store = await openFsStore(tmpStore);
|
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");
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
const { putSchema } = await import("@uncaged/json-cas");
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
const store = await openFsStore(tmpStore);
|
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(() => {
|
afterAll(() => {
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ beforeAll(async () => {
|
|||||||
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
const { putSchema } = await import("@uncaged/json-cas");
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
const store = await openFsStore(tmpStore);
|
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");
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
@@ -78,7 +81,16 @@ describe("Phase 6: GC", () => {
|
|||||||
expect(gcExit).toBe(0);
|
expect(gcExit).toBe(0);
|
||||||
|
|
||||||
const proc = Bun.spawn(
|
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" },
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
);
|
);
|
||||||
proc.stdin.write(gcOut);
|
proc.stdin.write(gcOut);
|
||||||
|
|||||||
@@ -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 { tmpdir } from "node:os";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import type { JSONSchema } from "@uncaged/json-cas";
|
import type { JSONSchema } from "@uncaged/json-cas";
|
||||||
import { putSchema } from "@uncaged/json-cas";
|
import { putSchema } from "@uncaged/json-cas";
|
||||||
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
|
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
|
||||||
export { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync };
|
export {
|
||||||
export { tmpdir };
|
join,
|
||||||
export { join, resolve };
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
resolve,
|
||||||
|
rmSync,
|
||||||
|
tmpdir,
|
||||||
|
writeFileSync,
|
||||||
|
};
|
||||||
|
|
||||||
export const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
export const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||||
export const pkgPath = resolve(import.meta.dir, "../package.json");
|
export const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,7 +28,10 @@ beforeAll(async () => {
|
|||||||
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
const { putSchema } = await import("@uncaged/json-cas");
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
const store = await openFsStore(tmpStore);
|
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");
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
@@ -143,7 +146,6 @@ describe("Phase 8: Pipe Composition", () => {
|
|||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
expect(stdout).toBe("Person: Carol (25)");
|
expect(stdout).toBe("Person: Carol (25)");
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Phase 9: Put/Hash Pipe Input ----
|
// ---- Phase 9: Put/Hash Pipe Input ----
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ describe("Phase 5: Render", () => {
|
|||||||
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
const { putSchema } = await import("@uncaged/json-cas");
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
const store = await openFsStore(tmpStore);
|
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");
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
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 { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
const { putSchema } = await import("@uncaged/json-cas");
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
const store = await openFsStore(tmpStore);
|
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");
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
const proc = Bun.spawn(
|
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" },
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
);
|
);
|
||||||
await proc.exited;
|
await proc.exited;
|
||||||
@@ -600,7 +613,17 @@ describe("Phase 2: Schema Validation", () => {
|
|||||||
const badFile = join(tmpStore, "bad-node.json");
|
const badFile = join(tmpStore, "bad-node.json");
|
||||||
writeFileSync(badFile, JSON.stringify({ name: 123 }));
|
writeFileSync(badFile, JSON.stringify({ name: 123 }));
|
||||||
const proc = Bun.spawn(
|
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" },
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
);
|
);
|
||||||
const exitCode = await proc.exited;
|
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 () => {
|
test("2.3 put against non-existent schema hash fails", async () => {
|
||||||
const nodeFile = join(tmpStore, "test-node.json");
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
const proc = Bun.spawn(
|
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" },
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
);
|
);
|
||||||
const exitCode = await proc.exited;
|
const exitCode = await proc.exited;
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ beforeAll(async () => {
|
|||||||
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
const { openStore: openFsStore } = await import("@uncaged/json-cas-fs");
|
||||||
const { putSchema } = await import("@uncaged/json-cas");
|
const { putSchema } = await import("@uncaged/json-cas");
|
||||||
const store = await openFsStore(tmpStore);
|
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");
|
const nodeFile = join(tmpStore, "test-node.json");
|
||||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
existsSync,
|
existsSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
@@ -10,6 +11,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { CasNode } from "@uncaged/json-cas";
|
import type { CasNode } from "@uncaged/json-cas";
|
||||||
import {
|
import {
|
||||||
|
BOOTSTRAP_STORE,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
computeHash,
|
computeHash,
|
||||||
computeSelfHash,
|
computeSelfHash,
|
||||||
@@ -65,7 +67,7 @@ describe("createFsStore – init and bootstrap", () => {
|
|||||||
const h2 = await bootstrap(store);
|
const h2 = await bootstrap(store);
|
||||||
|
|
||||||
expect(h1).toEqual(h2);
|
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);
|
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("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { decode } from "cborg";
|
import { decode } from "cborg";
|
||||||
|
|
||||||
const INDEX_DIR = "_index";
|
const INDEX_DIR = "_index";
|
||||||
|
const META_FILE = "_meta";
|
||||||
|
|
||||||
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
|
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
@@ -100,6 +101,61 @@ function loadOrMigrateTypeIndex(
|
|||||||
return loadTypeIndex(indexDir);
|
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(
|
function appendToTypeIndex(
|
||||||
indexDir: string,
|
indexDir: string,
|
||||||
typeIndex: Map<Hash, Hash[]>,
|
typeIndex: Map<Hash, Hash[]>,
|
||||||
@@ -118,6 +174,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
|||||||
loadDir(dir, data);
|
loadDir(dir, data);
|
||||||
const indexDir = join(dir, INDEX_DIR);
|
const indexDir = join(dir, INDEX_DIR);
|
||||||
const typeIndex = loadOrMigrateTypeIndex(dir, data);
|
const typeIndex = loadOrMigrateTypeIndex(dir, data);
|
||||||
|
const metaSet = loadOrMigrateMetaSet(dir, data);
|
||||||
|
|
||||||
async function putSelfReferencing(payload: unknown): Promise<Hash> {
|
async function putSelfReferencing(payload: unknown): Promise<Hash> {
|
||||||
const hash = await computeSelfHash(payload);
|
const hash = await computeSelfHash(payload);
|
||||||
@@ -136,6 +193,7 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
|||||||
|
|
||||||
appendToTypeIndex(indexDir, typeIndex, hash, hash);
|
appendToTypeIndex(indexDir, typeIndex, hash, hash);
|
||||||
}
|
}
|
||||||
|
appendToMetaSet(indexDir, metaSet, hash);
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +240,22 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
|||||||
return Array.from(data.keys());
|
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 {
|
delete(hash: Hash): void {
|
||||||
const node = data.get(hash);
|
const node = data.get(hash);
|
||||||
if (node) {
|
if (node) {
|
||||||
@@ -213,6 +287,11 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
|||||||
writeFileSync(join(indexDir, node.type), body, "utf8");
|
writeFileSync(join(indexDir, node.type), body, "utf8");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Remove from meta set if applicable
|
||||||
|
if (metaSet.has(hash)) {
|
||||||
|
metaSet.delete(hash);
|
||||||
|
rewriteMetaSet(indexDir, metaSet);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const OUTPUT_ALIASES = [
|
|||||||
"@output/refs",
|
"@output/refs",
|
||||||
"@output/walk",
|
"@output/walk",
|
||||||
"@output/list",
|
"@output/list",
|
||||||
|
"@output/list-meta",
|
||||||
|
"@output/list-schema",
|
||||||
"@output/var-set",
|
"@output/var-set",
|
||||||
"@output/var-get",
|
"@output/var-get",
|
||||||
"@output/var-delete",
|
"@output/var-delete",
|
||||||
@@ -30,11 +32,11 @@ const OUTPUT_ALIASES = [
|
|||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("bootstrap - Built-in Schemas", () => {
|
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 store = createMemoryStore();
|
||||||
const builtinSchemas = await bootstrap(store);
|
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("@schema");
|
||||||
expect(builtinSchemas).toHaveProperty("@string");
|
expect(builtinSchemas).toHaveProperty("@string");
|
||||||
expect(builtinSchemas).toHaveProperty("@number");
|
expect(builtinSchemas).toHaveProperty("@number");
|
||||||
@@ -46,7 +48,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
|||||||
expect(builtinSchemas).toHaveProperty(alias);
|
expect(builtinSchemas).toHaveProperty(alias);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(Object.keys(builtinSchemas)).toHaveLength(24);
|
expect(Object.keys(builtinSchemas)).toHaveLength(26);
|
||||||
|
|
||||||
// All values should be valid hashes
|
// All values should be valid hashes
|
||||||
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
||||||
@@ -316,3 +318,23 @@ describe("bootstrap - @output/* Schemas", () => {
|
|||||||
expect(uniqueHashes.size).toBe(OUTPUT_ALIASES.length);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -172,6 +172,22 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
|||||||
title: "ucas list result",
|
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",
|
"@output/var-set",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 store = createMemoryStore();
|
||||||
const builtinSchemas = await bootstrap(store);
|
const builtinSchemas = await bootstrap(store);
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ describe("bootstrap", () => {
|
|||||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
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 () => {
|
test("meta-schema node is stored and retrievable", async () => {
|
||||||
@@ -318,7 +318,7 @@ describe("bootstrap", () => {
|
|||||||
const h2 = await bootstrap(store);
|
const h2 = await bootstrap(store);
|
||||||
|
|
||||||
expect(h1).toEqual(h2);
|
expect(h1).toEqual(h2);
|
||||||
// All 24 built-in schemas should be typed by the meta-schema
|
// All 26 built-in schemas should be typed by the meta-schema
|
||||||
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(24);
|
expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(26);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ export class MemStore implements BootstrapCapableStore {
|
|||||||
return this.#inner.listAll();
|
return this.#inner.listAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listMeta(): Hash[] {
|
||||||
|
return this.#inner.listMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
listSchemas(): Hash[] {
|
||||||
|
return this.#inner.listSchemas();
|
||||||
|
}
|
||||||
|
|
||||||
delete(hash: Hash): void {
|
delete(hash: Hash): void {
|
||||||
this.#inner.delete(hash);
|
this.#inner.delete(hash);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import type { CasNode, Hash } from "./types.js";
|
|||||||
export function createMemoryStore(): BootstrapCapableStore {
|
export function createMemoryStore(): BootstrapCapableStore {
|
||||||
const data = new Map<Hash, CasNode>();
|
const data = new Map<Hash, CasNode>();
|
||||||
const byType = new Map<Hash, Set<Hash>>();
|
const byType = new Map<Hash, Set<Hash>>();
|
||||||
|
const metaSet = new Set<Hash>();
|
||||||
|
|
||||||
function indexHash(type: Hash, hash: Hash): void {
|
function indexHash(type: Hash, hash: Hash): void {
|
||||||
let set = byType.get(type);
|
let set = byType.get(type);
|
||||||
@@ -24,6 +25,7 @@ export function createMemoryStore(): BootstrapCapableStore {
|
|||||||
data.set(hash, { type: hash, payload, timestamp: Date.now() });
|
data.set(hash, { type: hash, payload, timestamp: Date.now() });
|
||||||
indexHash(hash, hash);
|
indexHash(hash, hash);
|
||||||
}
|
}
|
||||||
|
metaSet.add(hash);
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,22 @@ export function createMemoryStore(): BootstrapCapableStore {
|
|||||||
return Array.from(data.keys());
|
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 {
|
delete(hash: Hash): void {
|
||||||
const node = data.get(hash);
|
const node = data.get(hash);
|
||||||
if (node) {
|
if (node) {
|
||||||
@@ -68,6 +86,7 @@ export function createMemoryStore(): BootstrapCapableStore {
|
|||||||
byType.delete(node.type);
|
byType.delete(node.type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
metaSet.delete(hash);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -25,5 +25,7 @@ export type Store = {
|
|||||||
has(hash: Hash): boolean;
|
has(hash: Hash): boolean;
|
||||||
listByType(typeHash: Hash): Hash[];
|
listByType(typeHash: Hash): Hash[];
|
||||||
listAll(): Hash[];
|
listAll(): Hash[];
|
||||||
|
listMeta(): Hash[];
|
||||||
|
listSchemas(): Hash[];
|
||||||
delete(hash: Hash): void;
|
delete(hash: Hash): void;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user