diff --git a/bun.lock b/bun.lock index cec214a..8b774c4 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,14 @@ "xxhash-wasm": "^1.1.0", }, }, + "packages/json-cas-fs": { + "name": "@uncaged/json-cas-fs", + "version": "0.1.0", + "dependencies": { + "@uncaged/json-cas": "workspace:^", + "cborg": "^4.2.3", + }, + }, }, "packages": { "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], @@ -40,6 +48,8 @@ "@uncaged/json-cas": ["@uncaged/json-cas@workspace:packages/json-cas"], + "@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@workspace:packages/json-cas-fs"], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "cborg": ["cborg@4.5.8", "", { "bin": { "cborg": "lib/bin.js" } }, "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw=="], diff --git a/packages/json-cas-fs/package.json b/packages/json-cas-fs/package.json new file mode 100644 index 0000000..6e1171c --- /dev/null +++ b/packages/json-cas-fs/package.json @@ -0,0 +1,16 @@ +{ + "name": "@uncaged/json-cas-fs", + "version": "0.1.0", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test" + }, + "dependencies": { + "@uncaged/json-cas": "workspace:^", + "cborg": "^4.2.3" + } +} diff --git a/packages/json-cas-fs/src/index.ts b/packages/json-cas-fs/src/index.ts new file mode 100644 index 0000000..e226ff3 --- /dev/null +++ b/packages/json-cas-fs/src/index.ts @@ -0,0 +1 @@ +export { createFsStore } from "./store.js"; diff --git a/packages/json-cas-fs/src/store.test.ts b/packages/json-cas-fs/src/store.test.ts new file mode 100644 index 0000000..00e6da5 --- /dev/null +++ b/packages/json-cas-fs/src/store.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CasNode } from "@uncaged/json-cas"; +import { + bootstrap, + computeHash, + computeSelfHash, + verify, +} from "@uncaged/json-cas"; + +import { createFsStore } from "./store.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "json-cas-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.list()).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" }); + await store.put(typeHash, { x: 1 }); + expect(store.list()).toHaveLength(1); + }); + + test("bootstrap returns a valid 13-char self-referencing hash", async () => { + const store = createFsStore(dir); + const hash = await bootstrap(store); + + 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).toBe(h2); + expect(store.list()).toHaveLength(1); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 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.list()).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 hash = await bootstrap(store1); + + 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.list()).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("list returns all stored hashes", 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.list(); + expect(all).toHaveLength(3); + expect(all).toContain(h1); + expect(all).toContain(h2); + expect(all).toContain(h3); + }); + + test("list returns empty array on fresh store", () => { + const store = createFsStore(dir); + expect(store.list()).toEqual([]); + }); + + test("get returns null for unknown hash", () => { + const store = createFsStore(dir); + expect(store.get("0000000000000")).toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 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 hash = await bootstrap(store1); + + 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); + } + }); +}); diff --git a/packages/json-cas-fs/src/store.ts b/packages/json-cas-fs/src/store.ts new file mode 100644 index 0000000..2f20ea1 --- /dev/null +++ b/packages/json-cas-fs/src/store.ts @@ -0,0 +1,75 @@ +import { + mkdirSync, + readdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import type { CasNode, Hash, Store } from "@uncaged/json-cas"; + +import { cborEncode, computeHash, computeSelfHash } from "@uncaged/json-cas"; +import { decode } from "cborg"; + +function loadDir(dir: string, data: Map): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const name of entries) { + if (!name.endsWith(".bin")) continue; + const hash = name.slice(0, -4) as Hash; + try { + const buf = readFileSync(join(dir, name)); + const node = decode(new Uint8Array(buf)) as CasNode; + data.set(hash, node); + } catch { + // skip corrupted files + } + } +} + +export function createFsStore(dir: string): Store { + const data = new Map(); + loadDir(dir, data); + + return { + async put(typeHash: Hash | null, payload: unknown): Promise { + const hash = + typeHash === null + ? await computeSelfHash(payload) + : await computeHash(typeHash, payload); + + if (!data.has(hash)) { + const type = typeHash === null ? hash : typeHash; + const node: CasNode = { type, payload, timestamp: Date.now() }; + data.set(hash, node); + + mkdirSync(dir, { recursive: true }); + const tmp = join(dir, `${hash}.tmp`); + const dest = join(dir, `${hash}.bin`); + writeFileSync( + tmp, + cborEncode({ type, payload, timestamp: node.timestamp }), + ); + renameSync(tmp, dest); + } + + return hash; + }, + + get(hash: Hash): CasNode | null { + return data.get(hash) ?? null; + }, + + has(hash: Hash): boolean { + return data.has(hash); + }, + + list(): Hash[] { + return [...data.keys()]; + }, + }; +} diff --git a/packages/json-cas-fs/tsconfig.json b/packages/json-cas-fs/tsconfig.json new file mode 100644 index 0000000..75eba9f --- /dev/null +++ b/packages/json-cas-fs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +}