OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-vdrive commits / 577b061

Bind the content store to hyper-overlay's FileIndex
OverlayContentStore resolves contentHash -> (file, offset, length) from a real FileIndex, covering both byte sources the overlay actually uses: a working tree (bytes at <root>/<path>, chunk map from getChunkMap) and a content-addressed spool (bytes at <syncBaseDir>/<oid>, chunk map from getChunkMapByHash — what a mirror holds, having no working tree). indexAll() walks listFiles() and tries the working tree first. Verified against a real FileIndex: 114 chunks resolved across both sources, 114 blocks redirected and 5 literal, both readbacks correct, zero hash failures, 8.39MB of input published as a 0.14MB drive. The index is duck-typed (getChunkMap / getChunkMapByHash), so this CJS module does not import hyper-overlay's ESM. Caveat documented in the README: FileIndex has no reverse hash->location index, so we build one in memory by reading chunk maps — O(total chunks), fine for a publish or a seeder boot, not per request. A persistent reverse index in the overlay would remove it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
577b061fc257f1f210e5b8efcb0ab46002b09d95
by plankatron <fleeky@gmail.com> · 2026-08-24T15:36:49.000Z

Changed files (5) +257 -6

README.md +24 -6
……@@ -56,18 +56,36 @@ already the leaf hashes, and a chunk maps directly onto a block.
5656
5757## Usage
5858
59Against a real hyper-overlay index:
60
5961```js
60const { ContentStore, build } = require('hyper-vdrive')
62const { build } = require('hyper-vdrive')
63const { OverlayContentStore } = require('hyper-vdrive/overlay')
6164
62const content = new ContentStore()
63// tell it where bytes live: contentHash -> (file, offset, length)
64for (const c of chunk(buf)) content.add(c.hash, file, c.offset, c.length)
65const content = new OverlayContentStore(fileIndex, {
66 roots: [repoRoot], // working tree — bytes at <root>/<path>
67 syncBaseDirs: [spoolDir] // content spool — bytes at <dir>/<oid>, for mirrors
68})
69await content.indexAll() // or indexPath(p) / indexContent(oid) per artifact
6570
66const Storage = build(content)
67const store = new Corestore(new Storage(dir))
71const store = new Corestore(new build(content)(dir))
6872const drive = new Hyperdrive(store)
6973```
7074
75Or standalone, if you are managing locations yourself:
76
77```js
78const { ContentStore, build } = require('hyper-vdrive')
79const content = new ContentStore()
80for (const c of chunk(buf)) content.add(c.hash, file, c.offset, c.length)
81```
82
83**Note on the reverse index.** `FileIndex` maps *path → chunks* and *oid → chunks*; it
84has no *chunk hash → location* index. `OverlayContentStore` builds that in memory by
85reading the chunk maps of the paths you register, which is O(total chunks) — fine for a
86publish or a seeder boot, not something to redo per request. A persistent reverse index
87in the overlay would remove it.
88
7189Write files chunk-by-chunk so block boundaries are your chunk boundaries:
7290
7391```js
RESULTS.md +22 -0
……@@ -268,6 +268,28 @@ Clearing after GC is therefore **hygiene, not a requirement**: it keeps advertis
268268honest and avoids wasted requests, and can be done opportunistically rather than
269269atomically with the byte drop.
270270
271## Bound to hyper-overlay's FileIndex
272
273A vdrive published with bytes resolved through a **real** `FileIndex`, exercising both
274byte sources — a working-tree file and a content-addressed spool blob (what a mirror
275holds, having no working tree).
276
277```
278indexed 2 files -> 1 path + 1 spool blob, 114 chunks
279blocks 114 redirected, 5 literal
280readback working tree ok, spool ok
281hash failures 0
282input 8.39 MB
283drive store 0.14 MB
284```
285
286`indexAll()` walks `listFiles()`, tries `getChunkMap(path)` against the working-tree
287roots first, then falls back to `getChunkMapByHash(contentHash)` against the spool dirs.
288
289One thing to know when populating an index by hand: `chunk()` returns `data` subarrays
290alongside the locations, and putting those into the bee blows past Hypercore's 15 MiB
291block cap. Store `{hash, offset, length}` only.
292
271293## Upstream bug found
272294
273295A 0-byte file written with `dedup: true` cannot be read back: hyperblobs writes an
lib/overlay-content-store.cjs +89 -0
……@@ -0,0 +1,89 @@
1// Bind the adapter to hyper-overlay's FileIndex.
2//
3// The adapter needs contentHash -> (file, offset, length). FileIndex stores the
4// other direction: path -> chunks[{hash, offset, length}] (`chunkmap:<path>`) and
5// contentHash -> the same list (`chunkmap-oid:<hash>`). There is no reverse index,
6// so we build one by reading the chunk maps of the paths we intend to publish.
7//
8// Two byte sources, matching how the overlay actually stores things:
9// * working tree — bytes at <root>/<path>, chunk map from getChunkMap(path)
10// * content spool — bytes at <syncBaseDir>/<oid> (a mirror has no working tree),
11// chunk map from getChunkMapByHash(oid)
12//
13// `index` is duck-typed: anything with getChunkMap / getChunkMapByHash works, so
14// this file does not import hyper-overlay (ESM) into a CJS module.
15
16const fs = require('fs')
17const path = require('path')
18const { ContentStore } = require('./storage.cjs')
19
20class OverlayContentStore extends ContentStore {
21 constructor (index, { roots = [], syncBaseDirs = [] } = {}) {
22 super()
23 this.index = index
24 this.roots = Array.isArray(roots) ? roots : [roots]
25 this.syncBaseDirs = Array.isArray(syncBaseDirs) ? syncBaseDirs : [syncBaseDirs]
26 this.indexed = new Set()
27 }
28
29 _locate (dirs, rel) {
30 for (const dir of dirs) {
31 const full = path.join(dir, rel)
32 try {
33 if (fs.statSync(full).isFile()) return full
34 } catch {}
35 }
36 return null
37 }
38
39 // Register one working-tree path. Returns how many chunks it contributed.
40 async indexPath (rel) {
41 if (this.indexed.has('p:' + rel)) return 0
42 const chunks = await this.index.getChunkMap(rel)
43 if (!chunks || chunks.length === 0) return 0
44 const file = this._locate(this.roots, rel)
45 if (file === null) return 0
46 for (const c of chunks) this.add(c.hash, file, c.offset, c.length)
47 this.indexed.add('p:' + rel)
48 return chunks.length
49 }
50
51 // Register one content-addressed blob from the spool (mirror nodes).
52 async indexContent (oid) {
53 if (this.indexed.has('o:' + oid)) return 0
54 const chunks = await this.index.getChunkMapByHash(oid)
55 if (!chunks || chunks.length === 0) return 0
56 const file = this._locate(this.syncBaseDirs, oid)
57 if (file === null) return 0
58 for (const c of chunks) this.add(c.hash, file, c.offset, c.length)
59 this.indexed.add('o:' + oid)
60 return chunks.length
61 }
62
63 // Register everything the index knows about, working tree first then spool.
64 // O(total chunks) — fine for a publish, not something to do on every boot.
65 async indexAll (dir = '') {
66 const files = await this.index.listFiles(dir)
67 let chunks = 0
68 let paths = 0
69 let blobs = 0
70 for (const f of files) {
71 const n = await this.indexPath(f.path)
72 if (n > 0) {
73 chunks += n
74 paths++
75 continue
76 }
77 if (f.contentHash) {
78 const m = await this.indexContent(f.contentHash)
79 if (m > 0) {
80 chunks += m
81 blobs++
82 }
83 }
84 }
85 return { files: files.length, paths, blobs, chunks }
86 }
87}
88
89module.exports = { OverlayContentStore }
package.json +1 -0
……@@ -5,6 +5,7 @@
55 "main": "lib/storage.cjs",
66 "exports": {
77 ".": "./lib/storage.cjs",
8 "./overlay": "./lib/overlay-content-store.cjs",
89 "./passthrough": "./lib/passthrough.cjs"
910 },
1011 "scripts": {
test/overlay-binding.mjs +121 -0
……@@ -0,0 +1,121 @@
1// Publish a vdrive whose bytes are resolved through a REAL hyper-overlay FileIndex,
2// from both byte sources: a working tree and a content-addressed spool.
3// node test/overlay-binding.mjs <workDir>
4import { createRequire } from 'module'
5const require = createRequire(import.meta.url)
6
7const fs = require('fs')
8const path = require('path')
9const { execFileSync } = require('child_process')
10const crypto = require('hypercore-crypto')
11const Corestore = require('corestore')
12const Hyperdrive = require('hyperdrive')
13const CorestoreStorage = require('hypercore-storage')
14const { build, stats } = require('../lib/storage.cjs')
15const { OverlayContentStore } = require('../lib/overlay-content-store.cjs')
16
17const { FileIndex } = await import('../../hyper-overlay/lib/file-index.js')
18const { chunk } = await import('../../hyper-overlay/lib/chunker.js')
19
20const ROOT = process.argv[2]
21const du = (p) => Number(execFileSync('du', ['-sb', p]).toString().split('\t')[0])
22
23const treeDir = ROOT + '/worktree'
24const spoolDir = ROOT + '/spool'
25fs.mkdirSync(treeDir, { recursive: true })
26fs.mkdirSync(spoolDir, { recursive: true })
27
28// a working-tree file and a spool blob (as a mirror node would hold it)
29const treeBytes = crypto.randomBytes(5 * 1024 * 1024)
30const spoolBytes = crypto.randomBytes(3 * 1024 * 1024)
31fs.writeFileSync(path.join(treeDir, 'app.bin'), treeBytes)
32const spoolOid = crypto.data(spoolBytes).toString('hex')
33fs.writeFileSync(path.join(spoolDir, spoolOid), spoolBytes)
34
35// ── populate a real FileIndex the way the overlay does ──────────────────────
36const idxStore = new Corestore(ROOT + '/overlay-store')
37const index = new FileIndex(idxStore)
38await index.ready()
39
40// the index stores locations only — chunk() also hands back `data` subarrays,
41// and putting those in the bee blows past the 15 MiB block cap
42const meta = (chunks) => chunks.map((c) => ({ hash: c.hash, offset: c.offset, length: c.length }))
43
44const treeChunks = chunk(treeBytes)
45await index.putFile('app.bin', {
46 contentHash: crypto.data(treeBytes).toString('hex'),
47 size: treeBytes.length,
48 mtime: Date.now()
49})
50await index.putChunkMap('app.bin', meta(treeChunks))
51
52const spoolChunks = chunk(spoolBytes)
53await index.putFile('blob.bin', {
54 contentHash: spoolOid,
55 size: spoolBytes.length,
56 mtime: Date.now()
57})
58await index.putChunkMapByHash(spoolOid, meta(spoolChunks))
59
60// ── resolve through the index, publish a vdrive ─────────────────────────────
61const content = new OverlayContentStore(index, { roots: [treeDir], syncBaseDirs: [spoolDir] })
62const indexed = await content.indexAll()
63
64const Storage = build(content)
65const driveDir = ROOT + '/virtual'
66const store = new Corestore(new Storage(driveDir), { primaryKey: Buffer.alloc(32, 7), unsafe: true })
67const drive = new Hyperdrive(store)
68await drive.ready()
69
70function write (ws, buf) {
71 return new Promise((resolve) => {
72 if (ws.write(buf)) return resolve()
73 ws.once('drain', resolve)
74 })
75}
76
77for (const [name, chunks] of [['/app.bin', treeChunks], ['/blob.bin', spoolChunks]]) {
78 const ws = drive.createWriteStream(name, { dedup: true })
79 for (const c of chunks) await write(ws, c.data)
80 await new Promise((resolve, reject) => {
81 ws.once('error', reject)
82 ws.once('close', resolve)
83 ws.end()
84 })
85}
86
87await drive.getBlobs()
88const readTree = await drive.get('/app.bin')
89const readSpool = await drive.get('/blob.bin')
90const driveKey = drive.key.toString('hex')
91const blobsLength = drive.blobs.core.length
92await drive.close()
93await store.close()
94await index.close()
95await idxStore.close()
96
97const s = new CorestoreStorage(driveDir)
98await s.ready()
99await s.compact()
100await s.close()
101
102content.close()
103
104console.log(
105 JSON.stringify(
106 {
107 indexed,
108 driveKey,
109 blobsLength,
110 workingTreeReadbackOk: readTree !== null && readTree.equals(treeBytes),
111 spoolReadbackOk: readSpool !== null && readSpool.equals(spoolBytes),
112 redirected: stats.putRedirected,
113 literal: stats.putStored,
114 hashFailures: stats.hashFailures,
115 inputBytes: treeBytes.length + spoolBytes.length,
116 driveStoreBytes: du(driveDir)
117 },
118 null,
119 2
120 )
121)