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:
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createFsStore } from "./store.js";
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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()];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user