import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { zstdCompressSync, zstdDecompressSync } from "node:zlib"; import { sha256 } from "@websensor/core"; /** * Content-addressed blob store for snapshots and diffs. Identical content is stored once * (`sha256/ab/cd/.zst`). The interface is storage-agnostic so an S3/MinIO driver can * be added without touching callers. */ export interface BlobStore { put(data: Buffer | string, meta?: { contentType?: string }): Promise<{ key: string; bytes: number; deduplicated: boolean }>; get(key: string): Promise; getText(key: string): Promise; exists(key: string): Promise; } export class FsBlobStore implements BlobStore { constructor(private readonly root: string) {} private pathFor(key: string): string { return join(this.root, key + ".zst"); } static keyFor(hash: string): string { return `sha256/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`; } async put(data: Buffer | string): Promise<{ key: string; bytes: number; deduplicated: boolean }> { const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data; const key = FsBlobStore.keyFor(sha256(buf)); const p = this.pathFor(key); try { const s = await stat(p); if (s.size > 0) return { key, bytes: buf.length, deduplicated: true }; } catch { // not present } await mkdir(dirname(p), { recursive: true }); const tmp = `${p}.${process.pid}.${Date.now()}.tmp`; await writeFile(tmp, zstdCompressSync(buf)); await rename(tmp, p); return { key, bytes: buf.length, deduplicated: false }; } async get(key: string): Promise { const raw = await readFile(this.pathFor(key)); return zstdDecompressSync(raw); } async getText(key: string): Promise { return (await this.get(key)).toString("utf8"); } async exists(key: string): Promise { try { await stat(this.pathFor(key)); return true; } catch { return false; } } } let store: BlobStore | null = null; export function getBlobStore(): BlobStore { if (!store) store = new FsBlobStore(resolve(process.env.BLOB_STORE_DIR ?? "./data/blobs")); return store; }