diff --git a/packages/json-cas-fs/src/store.test.ts b/packages/json-cas-fs/src/store.test.ts index 00e6da5..5aeefca 100644 --- a/packages/json-cas-fs/src/store.test.ts +++ b/packages/json-cas-fs/src/store.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CasNode } from "@uncaged/json-cas"; @@ -177,6 +177,87 @@ describe("createFsStore – has and list", () => { }); }); +// ────────────────────────────────────────────────────────────────────────────── +// 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 hash = await bootstrap(store1); + + const store2 = createFsStore(dir); + expect(store2.listByType(hash)).toEqual([hash]); + }); +}); + // ────────────────────────────────────────────────────────────────────────────── // verify on disk-loaded nodes // ────────────────────────────────────────────────────────────────────────────── diff --git a/packages/json-cas-fs/src/store.ts b/packages/json-cas-fs/src/store.ts index 2f20ea1..af6bfc9 100644 --- a/packages/json-cas-fs/src/store.ts +++ b/packages/json-cas-fs/src/store.ts @@ -1,4 +1,6 @@ import { + appendFileSync, + existsSync, mkdirSync, readdirSync, readFileSync, @@ -11,6 +13,8 @@ import type { CasNode, Hash, Store } from "@uncaged/json-cas"; import { cborEncode, computeHash, computeSelfHash } from "@uncaged/json-cas"; import { decode } from "cborg"; +const INDEX_DIR = "_index"; + function loadDir(dir: string, data: Map): void { let entries: string[]; try { @@ -31,9 +35,81 @@ function loadDir(dir: string, data: Map): void { } } +function parseIndexFile(content: string): Hash[] { + if (content.length === 0) return []; + return content.split("\n").filter((line) => line.length > 0) as Hash[]; +} + +function loadTypeIndex(indexDir: string): Map { + const typeIndex = new Map(); + let entries: string[]; + try { + entries = readdirSync(indexDir); + } catch { + return typeIndex; + } + for (const typeHash of entries) { + try { + const content = readFileSync(join(indexDir, typeHash), "utf8"); + typeIndex.set(typeHash as Hash, parseIndexFile(content)); + } catch { + // skip unreadable index files + } + } + return typeIndex; +} + +function buildTypeIndexFromNodes(data: Map): Map { + const typeIndex = new Map(); + for (const [hash, node] of data) { + const list = typeIndex.get(node.type) ?? []; + list.push(hash); + typeIndex.set(node.type, list); + } + return typeIndex; +} + +function writeTypeIndex(indexDir: string, typeIndex: Map): void { + mkdirSync(indexDir, { recursive: true }); + for (const [typeHash, hashes] of typeIndex) { + const body = hashes.length > 0 ? `${hashes.join("\n")}\n` : ""; + writeFileSync(join(indexDir, typeHash), body, "utf8"); + } +} + +function loadOrMigrateTypeIndex( + dir: string, + data: Map, +): Map { + const indexDir = join(dir, INDEX_DIR); + if (!existsSync(indexDir)) { + const typeIndex = buildTypeIndexFromNodes(data); + if (typeIndex.size > 0) { + writeTypeIndex(indexDir, typeIndex); + } + return typeIndex; + } + return loadTypeIndex(indexDir); +} + +function appendToTypeIndex( + indexDir: string, + typeIndex: Map, + type: Hash, + hash: Hash, +): void { + mkdirSync(indexDir, { recursive: true }); + appendFileSync(join(indexDir, type), `${hash}\n`, "utf8"); + const list = typeIndex.get(type) ?? []; + list.push(hash); + typeIndex.set(type, list); +} + export function createFsStore(dir: string): Store { const data = new Map(); loadDir(dir, data); + const indexDir = join(dir, INDEX_DIR); + const typeIndex = loadOrMigrateTypeIndex(dir, data); return { async put(typeHash: Hash | null, payload: unknown): Promise { @@ -55,6 +131,8 @@ export function createFsStore(dir: string): Store { cborEncode({ type, payload, timestamp: node.timestamp }), ); renameSync(tmp, dest); + + appendToTypeIndex(indexDir, typeIndex, type, hash); } return hash; @@ -71,5 +149,9 @@ export function createFsStore(dir: string): Store { list(): Hash[] { return [...data.keys()]; }, + + listByType(typeHash: Hash): Hash[] { + return typeIndex.get(typeHash) ?? []; + }, }; } diff --git a/packages/json-cas/src/index.test.ts b/packages/json-cas/src/index.test.ts index bb6b2dc..fbcd248 100644 --- a/packages/json-cas/src/index.test.ts +++ b/packages/json-cas/src/index.test.ts @@ -150,6 +150,48 @@ describe("createMemoryStore – has and list", () => { }); }); +// ────────────────────────────────────────────────────────────────────────────── +// Step 4b: store.listByType() +// ────────────────────────────────────────────────────────────────────────────── +describe("createMemoryStore – listByType", () => { + test("returns empty array for unknown type", () => { + const store = createMemoryStore(); + expect(store.listByType("0000000000000")).toEqual([]); + }); + + test("returns all hashes for the given type", async () => { + const store = createMemoryStore(); + 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("idempotent put does not duplicate in listByType", async () => { + const store = createMemoryStore(); + const typeHash = await computeSelfHash({ name: "t" }); + + const h1 = await store.put(typeHash, { n: 1 }); + await store.put(typeHash, { n: 1 }); + + expect(store.listByType(typeHash)).toEqual([h1]); + }); + + test("bootstrap node is listed under its self type", async () => { + const store = createMemoryStore(); + const hash = await bootstrap(store); + + expect(store.listByType(hash)).toEqual([hash]); + }); +}); + // ────────────────────────────────────────────────────────────────────────────── // Step 5: verify() // ────────────────────────────────────────────────────────────────────────────── diff --git a/packages/json-cas/src/store.ts b/packages/json-cas/src/store.ts index ac20f4a..417eb35 100644 --- a/packages/json-cas/src/store.ts +++ b/packages/json-cas/src/store.ts @@ -3,6 +3,16 @@ import type { CasNode, Hash, Store } from "./types.js"; export function createMemoryStore(): Store { const data = new Map(); + const byType = new Map>(); + + function indexHash(type: Hash, hash: Hash): void { + let set = byType.get(type); + if (!set) { + set = new Set(); + byType.set(type, set); + } + set.add(hash); + } return { async put(typeHash: Hash | null, payload: unknown): Promise { @@ -14,6 +24,7 @@ export function createMemoryStore(): Store { if (!data.has(hash)) { const type = typeHash === null ? hash : typeHash; data.set(hash, { type, payload, timestamp: Date.now() }); + indexHash(type, hash); } return hash; @@ -30,5 +41,10 @@ export function createMemoryStore(): Store { list(): Hash[] { return [...data.keys()]; }, + + listByType(typeHash: Hash): Hash[] { + const set = byType.get(typeHash); + return set ? [...set] : []; + }, }; } diff --git a/packages/json-cas/src/types.ts b/packages/json-cas/src/types.ts index 28543b5..44afc65 100644 --- a/packages/json-cas/src/types.ts +++ b/packages/json-cas/src/types.ts @@ -24,4 +24,5 @@ export type Store = { get(hash: Hash): CasNode | null; has(hash: Hash): boolean; list(): Hash[]; + listByType(typeHash: Hash): Hash[]; };