feat: Phase 3 — filesystem backend (@uncaged/json-cas-fs)

- createFsStore(dir) implementing Store interface
- CBOR binary blob storage with atomic writes (tmp+rename)
- Auto-create directory, idempotent put
- 15 new tests (64 total), biome clean

Closes #5
小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-17 09:31:04 +00:00
parent 913419981c
commit 49b1d5d665
6 changed files with 339 additions and 0 deletions
+10
View File
@@ -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=="],
+16
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
export { createFsStore } from "./store.js";
+229
View File
@@ -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);
}
});
});
+75
View File
@@ -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<Hash, CasNode>): 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<Hash, CasNode>();
loadDir(dir, data);
return {
async put(typeHash: Hash | null, payload: unknown): Promise<Hash> {
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()];
},
};
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}