// OverlayBlobStore — atproto blobs (avatars/images) stored as REAL content-addressed FILES on disk,
// deduped + distributed by hyper-overlay. The drop-in replacement for BlobStore's Hyperbee-of-bytes
// (which stored [len][header][BYTES] as bee values → an append-only balloon, the exact model the
// records redesign fled). Here the BYTES are files (FastCDC dedup, no corestore balloon); the bee
// holds only a TINY per-blob record { mimeType, size, oid } — metadata + the overlay content hash
// (oid) so a peer that has the metadata can fetch the bytes by hash cross-node.
//
// Same public API as BlobStore (drop-in): ready / put(bytes,mime)->{cid,mimeType,size} /
// get(cidStr)->{mimeType,size,bytes}|null / has / close. `cid` is the atproto blob CID
// (cidForBlobSync — raw codec); the overlay `oid` is blake2b (hypercore-crypto data()) — distinct,
// hence the mapping in the bee.
//
// overlay = a HyperOverlay (injected — this module never imports hyper-overlay, so it stays
// dual-runtime like OverlayRepoStore). blobRoot = dir for the canonical blob files. indexStore = a
// Corestore for the tiny metadata bee.
import Corestore from 'corestore'
import { isCorestore } from './corestore.js'
import Hyperbee from 'hyperbee'
import b4a from 'b4a'
import fs from '#node:fs' // dual-runtime: bare-fs under Bare (the daemon), builtin fs under Node (tests)
import { join } from '#node:path'
import { cidForBlobSync } from 'atproto-repo/lib/cid.js'
export class OverlayBlobStore {
constructor (blobRoot, { overlay = null, indexStore, name = 'blob-index' } = {}) {
if (typeof blobRoot !== 'string') throw new Error('OverlayBlobStore: blobRoot dir required')
if (!indexStore) throw new Error('OverlayBlobStore: indexStore (Corestore) required')
this._blobRoot = blobRoot
this._overlay = overlay
// Behavioural check, NOT `instanceof` — a second copy of corestore in a bundled tree makes a real
// store look like a path and Corestore then path.join()s an object. See lib/corestore.js.
this._ownStore = !isCorestore(indexStore)
this._store = this._ownStore ? new Corestore(indexStore) : indexStore
this._name = name
this.bee = null
}
async ready () {
fs.mkdirSync(this._blobRoot, { recursive: true })
const core = this._store.get({ name: this._name })
await core.ready()
// metadata only (tiny JSON) — NOT the blob bytes. root bee, alwaysDuplicate:false is safe here.
this.bee = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'binary', alwaysDuplicate: false })
await this.bee.ready()
return this
}
_pathFor (cidStr) { return join(this._blobRoot, cidStr) }
// Store bytes as a content-addressed file + register with the overlay (dedup + servable by oid).
// Idempotent (content-addressed): the same bytes always map to the same cid/file.
async put (bytes, mimeType = 'application/octet-stream') {
const buf = b4a.isBuffer(bytes) ? bytes : b4a.from(bytes)
const cid = cidForBlobSync(buf)
const cidStr = cid.toString()
const path = this._pathFor(cidStr)
fs.writeFileSync(path, buf)
let oid = null
if (this._overlay) {
const r = await this._overlay.registerFile('/blobs/' + cidStr, path, { size: buf.length })
oid = r ? r.contentHash : null
}
await this.bee.put(cidStr, b4a.from(JSON.stringify({ mimeType, size: buf.length, oid }), 'utf-8'))
// `oid` is returned so a caller can publish it as a SHARED pointer (see BlobPointerStore). The bee
// here is node-local and never replicates, so without propagating the oid a co-owner that receives
// the blob FILE still cannot serve it — get() below returns null on a missing bee entry.
return { cid, mimeType, size: buf.length, oid }
}
// Record metadata for a blob this node did NOT upload — learned from a replicated source (the shared
// pointer set). Writing the bee entry is what makes get() able to resolve the blob at all: get() reads
// the bee FIRST and returns null when it is absent, even if the bytes are already on disk. After this,
// the ordinary get() path applies unchanged (local file hit, else fetchFile by oid).
async adoptMeta (cidStr, { mimeType = 'application/octet-stream', size = 0, oid = null } = {}) {
const existing = await this.bee.get(cidStr)
if (existing) return false // never clobber locally-known metadata
await this.bee.put(cidStr, b4a.from(JSON.stringify({ mimeType, size, oid }), 'utf-8'))
return true
}
// Fetch by CID string; returns { mimeType, size, bytes } or null. Serves the local file if present;
// otherwise pulls the bytes from a connected peer by the overlay oid (content-hash verified).
async get (cidStr) {
const node = await this.bee.get(cidStr)
if (!node) return null
const meta = JSON.parse(b4a.toString(node.value, 'utf-8'))
const path = this._pathFor(cidStr)
let bytes = null
try {
bytes = fs.readFileSync(path)
} catch (e) {
if (!e || e.code !== 'ENOENT') throw e
if (this._overlay && meta.oid) {
const r = await this._overlay.fetchFile(meta.oid, { destPath: path })
if (r) { try { bytes = fs.readFileSync(path) } catch { bytes = null } }
}
}
if (!bytes) return null
return { mimeType: meta.mimeType, size: meta.size, bytes }
}
async has (cidStr) { return !!(await this.bee.get(cidStr)) }
// Do we hold the BYTES on disk? Distinct from has(), which only reports that the local metadata bee
// knows this cid — a blob can have an ADOPTED bee entry (learned from a shared pointer) with no file
// yet. Reconciliation needs the byte-level answer, and needs it CHEAP: a stat, not a read, so a pass
// over hundreds of blobs does not pull every one into memory just to discover it is already present.
hasLocalFile (cidStr) {
try { return fs.statSync(this._pathFor(String(cidStr))).isFile() } catch { return false }
}
// Everything this node knows locally: { cid, mimeType, size, oid }. The input to pointer backfill.
async * entries () {
for await (const node of this.bee.createReadStream()) {
const cid = typeof node.key === 'string' ? node.key : b4a.toString(node.key, 'utf-8')
let meta
try { meta = JSON.parse(b4a.toString(node.value, 'utf-8')) } catch { continue }
yield { cid, mimeType: meta.mimeType, size: meta.size, oid: meta.oid || null }
}
}
// EXPLICIT REPAIR — the blob counterpart of OverlayRepoStore.reindexOverlay, for a store whose overlay
// INDEX is missing or stale. That method walks records/ ONLY and never touches blobs, so a repaired
// node would resume serving records by oid while silently still failing every blob.
//
// Walks the blob FILES (the canonical bytes; each filename IS its atproto CID) rather than the bee,
// because the bee is exactly what may be impoverished. Deliberately never INVENTS metadata: a file
// with no bee entry is registered — so its bytes become fetchable by oid — but is reported as
// `unknownMeta` rather than given a guessed mimeType, which would be served to clients as fact. Such a
// blob is recoverable once its shared pointer replicates (BlobPointerStore.get adopts mime from there).
// `prepare:false` matches the record path: the serve path chunks lazily on first request.
async reindexOverlay () {
if (!this._overlay) return { skipped: 'no-overlay' }
let names
try { names = fs.readdirSync(this._blobRoot) } catch (e) { if (e && e.code === 'ENOENT') return { registered: 0, failed: 0, metaUpdated: 0, unknownMeta: 0, total: 0 }; throw e }
let registered = 0
let failed = 0
let metaUpdated = 0
let unknownMeta = 0
for (const cidStr of names) {
const path = this._pathFor(cidStr)
let size = 0
try { const st = fs.statSync(path); if (!st.isFile()) continue; size = st.size } catch { continue }
let oid = null
try {
const r = await this._overlay.registerFile('/blobs/' + cidStr, path, { size, prepare: false })
oid = r ? r.contentHash : null
} catch { failed++; continue }
if (!oid) { failed++; continue }
registered++
const node = await this.bee.get(cidStr)
if (!node) { unknownMeta++; continue }
try {
const meta = JSON.parse(b4a.toString(node.value, 'utf-8'))
if (meta.oid !== oid) {
meta.oid = oid
await this.bee.put(cidStr, b4a.from(JSON.stringify(meta), 'utf-8'))
metaUpdated++
}
} catch {}
}
return { registered, failed, metaUpdated, unknownMeta, total: names.length }
}
async close () {
try { if (this.bee) await this.bee.close() } catch {}
if (this._ownStore) { try { await this._store.close() } catch {} }
}
}