OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-store / lib / overlay-blob-store.js

171 lines · javascript ref:
// 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 {} }
  }
}