Files
ocas/packages/fs/src/store.test.ts
T
xiaoju 777e58019a fix: rename @bool to @ocas/bool + add OCAS_HOME env var
- @bool alias missed in namespace rebranding, now @ocas/bool
- CLI respects OCAS_HOME env var for store path (--store > OCAS_HOME > ~/.ocas)
- Update help text and snapshots
2026-06-01 08:12:29 +00:00

561 lines
21 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasNode } from "@ocas/core";
import {
BOOTSTRAP_STORE,
bootstrap,
computeHash,
computeSelfHash,
verify,
} from "@ocas/core";
import { createFsStore, openStore } from "./store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "ocas-fs-test-"));
}
// ──────────────────────────────────────────────────────────────────────────────
// init and bootstrap
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – init and bootstrap", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("store opens against an existing empty dir", () => {
const store = createFsStore(dir);
expect(store.listByType("0000000000000")).toEqual([]);
});
test("store creates the directory on first put", async () => {
const nested = join(dir, "sub", "store");
const store = createFsStore(nested);
const typeHash = await computeSelfHash({ name: "t" });
const hash = await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
});
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
const store = createFsStore(dir);
const builtinSchemas = await bootstrap(store);
const hash = builtinSchemas["@ocas/schema"] ?? "";
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const node = store.get(hash) as CasNode;
expect(node.type).toBe(hash);
});
test("bootstrap is idempotent across calls", async () => {
const store = createFsStore(dir);
const h1 = await bootstrap(store);
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(26);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// persistence round-trip
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – persistence round-trip", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("second store instance reads nodes written by first", async () => {
const typeHash = await computeSelfHash({ name: "my-type" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { msg: "hello" });
const h2 = await store1.put(typeHash, { msg: "world" });
const store2 = createFsStore(dir);
expect(store2.has(h1)).toBe(true);
expect(store2.has(h2)).toBe(true);
expect(store2.listByType(typeHash)).toHaveLength(2);
});
test("round-trip preserves type, payload, and timestamp", async () => {
const typeHash = await computeSelfHash({ name: "round-trip" });
const store1 = createFsStore(dir);
const hash = await store1.put(typeHash, { value: 42 });
const original = store1.get(hash) as CasNode;
const store2 = createFsStore(dir);
const loaded = store2.get(hash) as CasNode;
expect(loaded.type).toBe(original.type);
expect(loaded.payload).toEqual(original.payload);
expect(loaded.timestamp).toBe(original.timestamp);
});
test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => {
const store1 = createFsStore(dir);
const builtinSchemas = await bootstrap(store1);
const hash = builtinSchemas["@ocas/schema"] ?? "";
const store2 = createFsStore(dir);
const node = store2.get(hash) as CasNode;
expect(node.type).toBe(hash);
});
test("put is idempotent across instances: timestamp unchanged on second put", async () => {
const typeHash = await computeSelfHash({ name: "idempotent" });
const store1 = createFsStore(dir);
const hash = await store1.put(typeHash, { n: 7 });
const ts1 = store1.get(hash)?.timestamp;
await new Promise((r) => setTimeout(r, 5));
const store2 = createFsStore(dir);
await store2.put(typeHash, { n: 7 });
const ts2 = store2.get(hash)?.timestamp;
expect(ts1).toBe(ts2);
expect(store2.listByType(typeHash)).toHaveLength(1);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// has and list
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – has and list", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("has returns false before put, true after", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
const hash = await computeHash(typeHash, { x: 1 });
expect(store.has(hash)).toBe(false);
await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
});
test("listByType returns all stored hashes for a type", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
const h1 = await store.put(typeHash, { a: 1 });
const h2 = await store.put(typeHash, { a: 2 });
const h3 = await store.put(typeHash, { a: 3 });
const all = store.listByType(typeHash);
expect(all).toHaveLength(3);
expect(all).toContain(h1);
expect(all).toContain(h2);
expect(all).toContain(h3);
});
test("listByType returns empty array on fresh store", () => {
const store = createFsStore(dir);
expect(store.listByType("0000000000000")).toEqual([]);
});
test("get returns null for unknown hash", () => {
const store = createFsStore(dir);
expect(store.get("0000000000000")).toBeNull();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// listByType and index migration
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – listByType", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("returns empty array for unknown type", () => {
const store = createFsStore(dir);
expect(store.listByType("0000000000000")).toEqual([]);
});
test("returns all hashes for the given type", async () => {
const store = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
const otherType = await computeSelfHash({ name: "other" });
const h1 = await store.put(typeHash, { a: 1 });
const h2 = await store.put(typeHash, { a: 2 });
await store.put(otherType, { b: 1 });
const byType = store.listByType(typeHash);
expect(byType).toHaveLength(2);
expect(byType).toContain(h1);
expect(byType).toContain(h2);
});
test("listByType survives round-trip across store instances", async () => {
const typeHash = await computeSelfHash({ name: "persist-by-type" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { x: 1 });
const h2 = await store1.put(typeHash, { x: 2 });
const store2 = createFsStore(dir);
const byType = store2.listByType(typeHash);
expect(byType).toHaveLength(2);
expect(byType).toContain(h1);
expect(byType).toContain(h2);
});
test("idempotent put does not duplicate in listByType", async () => {
const typeHash = await computeSelfHash({ name: "idempotent-index" });
const store1 = createFsStore(dir);
const hash = await store1.put(typeHash, { n: 7 });
await store1.put(typeHash, { n: 7 });
const store2 = createFsStore(dir);
expect(store2.listByType(typeHash)).toEqual([hash]);
});
test("rebuilds _index from .bin files when index is missing", async () => {
const typeHash = await computeSelfHash({ name: "migrate" });
const store1 = createFsStore(dir);
const h1 = await store1.put(typeHash, { a: 1 });
const h2 = await store1.put(typeHash, { a: 2 });
rmSync(join(dir, "_index"), { recursive: true, force: true });
const store2 = createFsStore(dir);
expect(store2.listByType(typeHash)).toEqual([h1, h2]);
expect(existsSync(join(dir, "_index", typeHash))).toBe(true);
expect(readdirSync(join(dir, "_index"))).toContain(typeHash);
});
test("bootstrap node is listed under its self type after reload", async () => {
const store1 = createFsStore(dir);
const builtinSchemas = await bootstrap(store1);
const hash = builtinSchemas["@ocas/schema"] ?? "";
const store2 = createFsStore(dir);
expect(store2.listByType(hash)).toContain(hash);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// verify on disk-loaded nodes
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – verify on disk-loaded nodes", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("verify passes on a normal disk-loaded node", async () => {
const typeHash = await computeSelfHash({ name: "verifiable" });
const store1 = createFsStore(dir);
const hash = await store1.put(typeHash, { data: 123 });
const store2 = createFsStore(dir);
const node = store2.get(hash) as CasNode;
expect(await verify(hash, node)).toBe(true);
});
test("verify passes on a disk-loaded bootstrap node", async () => {
const store1 = createFsStore(dir);
const builtinSchemas = await bootstrap(store1);
const hash = builtinSchemas["@ocas/schema"] ?? "";
const store2 = createFsStore(dir);
const node = store2.get(hash) as CasNode;
expect(await verify(hash, node)).toBe(true);
});
test("verify passes for multiple disk-loaded nodes", async () => {
const typeHash = await computeSelfHash({ name: "multi" });
const store1 = createFsStore(dir);
const hashes: string[] = [];
for (let i = 0; i < 5; i++) {
hashes.push(await store1.put(typeHash, { i }));
}
const store2 = createFsStore(dir);
for (const hash of hashes) {
const node = store2.get(hash) as CasNode;
expect(await verify(hash, node)).toBe(true);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// openStore – async with auto-bootstrap
// ──────────────────────────────────────────────────────────────────────────────
describe("openStore – async with auto-bootstrap", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("openStore returns Promise<Store>", async () => {
const store = await openStore(dir);
expect(store).toBeDefined();
expect(typeof store.put).toBe("function");
expect(typeof store.get).toBe("function");
});
test("openStore auto-creates directory when it doesn't exist", async () => {
const nested = join(dir, "sub", "nested", "store");
expect(existsSync(nested)).toBe(false);
const store = await openStore(nested);
expect(existsSync(nested)).toBe(true);
// Verify store works
const typeHash = await computeSelfHash({ name: "t" });
const hash = await store.put(typeHash, { x: 1 });
expect(store.has(hash)).toBe(true);
});
test("openStore works when directory already exists", async () => {
// Pre-create the directory
const store1 = await openStore(dir);
const typeHash = await computeSelfHash({ name: "t" });
await store1.put(typeHash, { x: 1 });
// Open again
const store2 = await openStore(dir);
expect(store2.listByType(typeHash)).toHaveLength(1);
});
test("openStore throws error when path exists but is not a directory", async () => {
const filePath = join(dir, "not-a-dir");
writeFileSync(filePath, "test");
await expect(openStore(filePath)).rejects.toThrow();
});
test("openStore auto-bootstraps on first open (empty directory)", async () => {
const store = await openStore(dir);
// Check that bootstrap schemas exist
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@ocas/schema"];
expect(metaHash).toBeDefined();
expect(store.has(metaHash as string)).toBe(true);
// Verify all core schemas exist
expect(store.has(builtinSchemas["@ocas/string"] as string)).toBe(true);
expect(store.has(builtinSchemas["@ocas/number"] as string)).toBe(true);
expect(store.has(builtinSchemas["@ocas/object"] as string)).toBe(true);
expect(store.has(builtinSchemas["@ocas/array"] as string)).toBe(true);
expect(store.has(builtinSchemas["@ocas/bool"] as string)).toBe(true);
expect(store.has(builtinSchemas["@ocas/schema"] as string)).toBe(true);
});
test("openStore bootstrap is idempotent on subsequent opens", async () => {
const store1 = await openStore(dir);
const schemas1 = await bootstrap(store1);
const count1 = store1.listAll().length;
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
const count2 = store2.listAll().length;
// Same schemas, same count
expect(schemas1).toEqual(schemas2);
expect(count1).toBe(count2);
});
test("openStore works on already-bootstrapped store", async () => {
// Bootstrap manually first
const store1 = createFsStore(dir);
const schemas1 = await bootstrap(store1);
// Open with openStore
const store2 = await openStore(dir);
const schemas2 = await bootstrap(store2);
expect(schemas1).toEqual(schemas2);
});
test("openStore auto-bootstraps old store without bootstrap", async () => {
// Create a store with some data but no bootstrap
const store1 = createFsStore(dir);
const typeHash = await computeSelfHash({ name: "custom" });
await store1.put(typeHash, { data: "old" });
// Open with openStore - should auto-bootstrap
const store2 = await openStore(dir);
const schemas = await bootstrap(store2);
expect(store2.has(schemas["@ocas/schema"] as string)).toBe(true);
// Old data still exists
expect(store2.listByType(typeHash)).toHaveLength(1);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// listMeta and listSchemas (FS persistence)
// ──────────────────────────────────────────────────────────────────────────────
describe("createFsStore – listMeta and listSchemas", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "ocas-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("");
}
});
});