OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-vdrive / lib / publish.cjs

123 lines · javascript ref:
// publishTree — write a set of files into a drive that stores no blob bytes.
//
// Lives here rather than in the caller so consumers (pear-git-v3's CI hook, the
// CLI) need only depend on hyper-vdrive: corestore/hyperdrive/id-encoding are
// resolved from this package, not theirs.
//
// The chunker is injected. This package deliberately does not depend on
// hyper-overlay — callers pass its `chunk`, or any function returning
// [{ hash, offset, length, data }].

const { isBare } = require('which-runtime')
const fs = isBare ? require('bare-fs') : require('fs')

const idEnc = require('hypercore-id-encoding')
const Corestore = require('corestore')
const Hyperdrive = require('hyperdrive')
const CorestoreStorage = require('hypercore-storage')
const { ContentStore, build, stats } = require('./storage.cjs')
const { saveLocations, loadLocations } = require('./locations.cjs')

function writeChunk (ws, buf) {
  return new Promise((resolve) => {
    if (ws.write(buf)) return resolve()
    ws.once('drain', resolve)
  })
}

/**
 * @param {object} opts
 * @param {string} opts.storeDir            where the drive lives (pointers only)
 * @param {Array<{name: string, file: string}>} opts.files  drive path -> source file on disk
 * @param {function} opts.chunk             content-defined chunker
 * @param {string} [opts.root]            record chunk locations relative to this,
 *                                     so a seeder can resolve them later
 * @param {object} [opts.corestoreOpts]
 * @returns {Promise<{link, key, blocks, files, bytes, pointerBlocks, literalBlocks}>}
 */
async function publishTree ({ storeDir, files, chunk, root, compact, corestoreOpts } = {}) {
  let result = null
  if (!storeDir) throw new Error('publishTree requires { storeDir }')
  if (!Array.isArray(files) || files.length === 0) throw new Error('publishTree requires files')
  if (typeof chunk !== 'function') throw new Error('publishTree requires a chunk function')

  const content = new ContentStore()

  // Republishing into an existing drive needs the PREVIOUS version's bytes to be
  // resolvable: `dedup: true` seeds its hash table by reading back the previous
  // entry's blocks, and those reads come through this content store. Without the
  // saved index those chunks resolve to null and the write stalls.
  const priorLocations = root ? loadLocations(storeDir, content, root) : 0

  const prepared = []
  let bytes = 0

  for (const f of files) {
    const data = fs.readFileSync(f.file)
    const chunks = chunk(data)
    for (const c of chunks) content.add(c.hash, f.file, c.offset, c.length)
    prepared.push({ name: f.name, chunks, size: data.byteLength })
    bytes += data.byteLength
  }

  const before = { redirected: stats.putRedirected, literal: stats.putStored }
  const Storage = build(content)
  const store = new Corestore(new Storage(storeDir), corestoreOpts)
  const drive = new Hyperdrive(store)

  try {
    await drive.ready()
    for (const f of prepared) {
      // 0-byte + dedup is an upstream hyperblobs crash on read
      const ws = drive.createWriteStream(f.name, f.size === 0 ? {} : { dedup: true })
      for (const c of f.chunks) await writeChunk(ws, c.data)
      await new Promise((resolve, reject) => {
        ws.once('error', reject)
        ws.once('close', resolve)
        ws.end()
      })
    }
    await drive.getBlobs()

    // Persist the reverse index so a seeder can serve this drive without
    // re-chunking every published file on boot.
    const locations = root ? saveLocations(storeDir, content.locations, root) : 0

    result = {
      locations,
      priorLocations,
      link: 'pear://' + idEnc.normalize(drive.key),
      key: drive.key.toString('hex'),
      blocks: drive.blobs.core.length,
      files: prepared.length,
      bytes,
      pointerBlocks: stats.putRedirected - before.redirected,
      literalBlocks: stats.putStored - before.literal
    }
    return result
  } finally {
    await drive.close().catch(() => {})
    await store.close().catch(() => {})
    content.close()

    // Compact BEFORE anyone else opens the store. RocksDB's footprint carries
    // the write churn of the publish — measured at 76 MB against a 609 KB
    // steady state — and on a daemon the seeder takes the exclusive lock the
    // moment the drive is announced, so this is the only window in which it can
    // be reclaimed without stopping the daemon.
    if (compact !== false) {
      try {
        const s = new CorestoreStorage(storeDir)
        await s.ready()
        await s.compact()
        await s.close()
        if (result) result.compacted = true
      } catch {
        if (result) result.compacted = false
      }
    }
  }
}

module.exports = { publishTree }