OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-vdrive commits / 3a0100d

Distil the README; move the detail into docs/
The README had grown into a design document with the measurements inline. It is now the short version — what it is, the headline numbers, how to use it — with three docs behind it: docs/design.md the storage seam, tagged block records, the locations index, and why `pear seed` cannot serve one of these docs/results.md every measurement, method and citations (was RESULTS.md) docs/gaps.md what is unproven or known-broken, gathered in one place rather than scattered through a status section results.md gains the end-to-end CI run, and a correction it implies: three different 49MB AppImage builds deduped at 97.7%, so compression does not zero dedup — STREAM compression does. A squashfs compresses per block, so a change disturbs only the blocks it touches. Which also means "git bundles do not dedup because they are compressed" is an inference from the tarball measurement, never measured on bundles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3a0100d67e1bd022b124b9a63c5a5d0f9791c707
by plankatron <fleeky@gmail.com> · 2026-08-25T00:43:09.000Z

Changed files (5) +552 -427

README.md +40 -128
……@@ -2,172 +2,84 @@
22
33A **real** Hyperdrive that stores **no blob bytes**.
44
5The drive is genuine all the way up — real Hyperbee metadata, real Hyperblobs core,
6real merkle tree, real signatures, stock `hyperdrive` / `hyperblobs` / `hypercore`
7with no patches. Only the storage layer underneath is swapped: block bytes are never
8written to RocksDB. Instead each block record holds a 32-byte content hash, and the
9bytes are read on demand from a content-addressed store already on disk.
5Real Hyperbee metadata, real Hyperblobs core, real merkle tree, real signatures,
6stock `hyperdrive` / `hyperblobs` / `hypercore` unpatched. Only the storage layer
7underneath is swapped: block bytes are never written to RocksDB. Each block record
8holds a 32-byte content hash instead, and the bytes are read on demand from a
9content-addressed store already on disk.
1010
11Peers can't tell. `pear install pear://<key>` works against it.
11Peers cannot tell. `pear install pear://<key>` works against it.
1212
1313## Why
1414
1515We already store every file once, chunked and content-addressed, in `hyper-overlay`.
16Publishing the same content as a Hyperdrive meant a second full copy in RocksDB. This
17removes the second copy while keeping `pear://` compatibility.
18
19Measured on ten consecutive real 367 MB game-pack releases:
16Publishing the same content as a Hyperdrive meant a second full copy in RocksDB.
17This removes the second copy and keeps `pear://` compatibility.
2018
2119| | stock drive | virtual drive |
2220|---|---|---|
23| store after 10 releases | 580.8 MB | **0.88 MB** |
24| publish time per release | 3.6 s | **2.4 s** |
21| ten 367 MB releases | 580.8 MB | **0.88 MB** |
22| app tree, 976 files / 209 MB | 210.1 MB | **1.38 MB** |
23| three 49 MB AppImages | 148 MB | **0.52 MB** |
24| publish time | 3.6 s | **2.4 s** |
2525| drive key | — | **identical** |
2626
27And on an app tree — 976 files, 209 MB, median 2 KB — 210.1 MB becomes **1.38 MB**
28(152×). Small files dilute the win because metadata, not bytes, becomes the cost:
29the floor is ~1.4 KB per file.
30
31See [RESULTS.md](RESULTS.md) for the full measurements and method. Measure stores
32*compacted* (`test/compact-size.mjs`) — RocksDB's raw footprint includes write churn
33that dominates a small store.
34
35## How
27Proven end to end: a real `pear install` on another machine, over the public DHT,
28byte-identical, from a drive holding zero blobs.
3629
37`Core` takes its per-core storage from exactly two calls
38(`hypercore/lib/core.js`): `db.resumeCore(discoveryKey)` and `db.createCore({...})`.
39Subclassing `CorestoreStorage` and wrapping those two return values puts us underneath
40every block read and write, with nothing above aware.
41
42Stored block records carry a one-byte tag:
30## Install
4331
4432```
450x00 | <original bytes> literal — bee blocks, and anything we don't hold
460x01 | <32-byte hash> pointer — bytes live in the content store
33npm install hyper-vdrive
4734```
4835
49Because the pointer *is* the block value, it commits in the same `CoreTX` as the tree
50node — tree and pointer land atomically, and `deleteBlock` / truncate free it with no
51extra bookkeeping. Cost: 1 byte per literal block, 33 bytes per virtualised block.
52
53The chunk hash is `hypercore-crypto`'s `data()` — the same construction hypercore uses
54for a merkle **leaf** (`merkle-tree.js`). So our existing FastCDC chunk hashes are
55already the leaf hashes, and a chunk maps directly onto a block.
56
57## Usage
58
59Against a real hyper-overlay index:
36## Use
6037
6138```js
6239const { build } = require('hyper-vdrive')
6340const { OverlayContentStore } = require('hyper-vdrive/overlay')
6441
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
42const content = new OverlayContentStore(fileIndex, { roots: [repoRoot] })
43await content.indexAll()
7044
71const store = new Corestore(new build(content)(dir))
45const store = new Corestore(new (build(content))(dir))
7246const drive = new Hyperdrive(store)
7347```
7448
75Or standalone, if you are managing locations yourself:
49Publishing and serving, which is what most callers want:
7650
7751```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.
52const { publishTree } = require('hyper-vdrive/publish')
53const { openDrive } = require('hyper-vdrive/serve')
8854
89Write files chunk-by-chunk so block boundaries are your chunk boundaries:
90
91```js
92const ws = drive.createWriteStream('/app.bin', { dedup: true })
93for (const c of chunks) ws.write(c.data) // honour backpressure
94ws.end()
55await publishTree({ storeDir, root, chunk, files }) // writes + compacts
56const opened = await openDrive({ storeDir, root }) // reopen to serve
9557```
9658
9759## CLI
9860
9961```
100vdrive publish <srcDir> --store <driveDir> [--only a,b]
62vdrive publish <srcDir> --store <driveDir> [--root <dir>] [--only a,b]
10163vdrive info --store <driveDir>
10264vdrive seed --store <driveDir> --root <srcDir> [--timeout <seconds>]
10365```
10466
105The source tree *is* the byte store — the drive holds pointers, and reads are served
106from the files under `<srcDir>` (or `--root` when seeding). Publishing is incremental:
107re-publishing into the same `--store` writes only what changed.
108
109End-to-end, verified against the stock `pear` CLI:
67The source tree *is* the byte store — the drive holds pointers, and reads are
68served from the files beneath it. Republishing into the same store is incremental.
11069
111```
112$ vdrive publish ./app --store ./appdrive
113link: pear://1a1kwj1u8ygcnpw1czu57wya6ym7fpp7ji93gy5qapnau43ubgjo
114files: 2 bytes: 4194432 blocks: 53
115pointerBlocks: 51 literalBlocks: 5 driveStoreBytes: 133277
116
117$ vdrive seed --store ./appdrive --root ./app &
118$ pear install pear://1a1kwj1u8ygcn... --to ./installed
119Installed # byte-identical, executable, runs
120```
70## Docs
12171
122For a `pear install`-able drive the tree needs `/package.json` (with `name` and `bin`)
123and `/by-arch/<platform>-<arch>/app/<bin>`. `vdrive` does not impose that layout — it
124publishes whatever tree you point it at.
72- **[docs/design.md](docs/design.md)** — how it works: the storage seam, the tagged
73 block records, the locations index, and why `pear seed` cannot serve one of these.
74- **[docs/results.md](docs/results.md)** — every measurement, with method and source
75 citations.
76- **[docs/gaps.md](docs/gaps.md)** — what is unproven or known-broken. Read this
77 before relying on it.
12578
126Note: `seed` prints its served-bytes line on SIGINT/SIGTERM, but that is best-effort —
127under a hard kill or a process-group teardown it will not appear.
79## Status
12880
129## Tests
130
131Tests import the FastCDC chunker from a sibling `hyper-overlay` checkout
132(`../../hyper-overlay/lib/chunker.js`) rather than vendoring a copy that could drift.
133
134```
135npm install
136node test/pass-through.mjs ./work/passthrough # seam holds, identical drive key
137node test/redirect.mjs ./work/redirect <f1> <f2>
138node test/releases.mjs ./work/releases <f...> # N-release size curve
139node test/gc-bitfield.mjs ./work/gc # GC / bitfield pairing
140node example/publish.mjs ./work/app # pear-install-shaped drive
141node example/seed.mjs ./work/app # serve it on the DHT
142```
81A working spike, running in `pear-git-v3` behind an opt-in `vdrive:` key in
82`ci.yaml`. It publishes, seeds and installs for real. The gaps are honest ones,
83and encryption defeats it entirely — see `docs/gaps.md`.
14384
144## Status — spike
145
146Proven: identical drive key/blobs key/root hash, reopen from disk, replication to a
147stock peer, a real `pear install` from a drive holding zero blobs, parity through
148clear/truncate, and crash-during-append leaving no tree node without its pointer.
149
150Known gaps, in the order they'd bite:
151
1521. **GC should be paired, but need not be atomic.** Dropping bytes does not update the
153 bitfield, so the core goes on advertising blocks it cannot produce. Measured with two
154 seeders, that is survivable — the replicator routes around it and a second source
155 serves the data in the same ~120 ms. So clearing the block range is hygiene rather
156 than a correctness requirement, and can be done opportunistically. What *does* matter:
157 with `dedup: true`, old releases share blocks with new ones, so ranges must be
158 computed from the content store's refcounts, never from a version's block range.
1592. **Block streams** (`createBlockStream`, used by audit/copy-prologue) yield raw tagged
160 records; they need decoding like `getBlock` does.
1613. **Encryption defeats virtualisation** — measured: 0 of 105 blocks redirect, the store
162 goes from 0.14 MB to 8.53 MB. Correctness is unaffected (fall-through-by-hash
163 degrades to a normal drive). Recoverable in principle: the cipher is deterministic
164 per (index, fork, key), so an adapter holding the key could decrypt on write and
165 re-encrypt on read, with dedup surviving because hyperblobs hashes plaintext. Until
166 then, publish public apps unencrypted.
1674. **Unsupported seam.** `hypercore-storage` has no public pluggable-storage contract
168 since `random-access-storage` was dropped. Pin versions and run the tests as a canary.
1695. **clear-then-truncate is unsupported upstream** — `clear()` prunes merkle tree nodes,
170 so a later `truncate()` fails with `Expected tree node N from storage, got (nil)`.
171 Same on a stock drive. And `clear()` reclaims no disk promptly: on a virtual drive
172 that does not matter, because the bytes are the overlay's to free.
1736. Untested: power loss — SIGKILL is covered and passes, fsync durability is not.
85AGPL-3.0-or-later.
RESULTS.md +0 -299
……@@ -1,299 +0,0 @@
1# Measurements
2
3> **Measure compacted.** RocksDB's on-disk footprint includes uncompacted WAL/SST
4> churn, which is noise on a big store and dominant on a small one. The small-file
5> store below read 14.4 MB before compaction and **1.38 MB** after. The large-file
6> stores barely moved (±0.05 MB), so those figures were already steady-state.
7> `test/compact-size.mjs` does the compact-then-measure.
8
9All runs on tabertron, 2026-08-24. Source data: ten consecutive real `hypertps.pck`
10release artifacts (~367 MB each) from `~/.local/share/store/dependencies`, and
11synthetic files where noted. Chunker: `hyper-overlay/lib/chunker.js` (FastCDC,
12tier 2 → ~320 KB average chunk).
13
14## T0 — does stock Pear read chunk-sized blocks?
15
16The installed `pear` 3.0.1 bundle (`~/.config/pear/current/boot.bundle`) contains
17`node_modules/hyperblobs/lib/block-map.js` and the marker strings `Block map is too
18large`, `Unsupported block map version`, `_dedup`, `getBlockMap`. So the shipped
19reader supports block maps and variable-size blocks. **Pass.**
20
21## T1 — storage cost, one release pair
22
23Two consecutive releases, 367.06 MB each, into one drive at the same path.
24
25| Arm | after v1 | after v2 | v2 delta |
26|---|---|---|---|
27| overlay (control) | 360.1 MB | 378.3 MB | 18.2 MB |
28| hyperdrive, no dedup | 368.3 MB | 736.4 MB | 368.1 MB |
29| hyperdrive, `dedup:true`, fixed 64 KB | 367.6 MB | 488.7 MB | 121.2 MB |
30| hyperdrive, `dedup:true`, FastCDC | 360.5 MB | 378.7 MB | 18.2 MB |
31
32Two corrections to earlier assumptions:
33
34- RocksDB per-byte overhead is **0.3%**, not a multiplier (368.3 MB stored for
35 367.06 MB in). The corestore column family enables blob files with GC.
36- Hyperblobs **does** dedup, at block level, via `{dedup:true}` + block maps
37 (`hyperblobs/lib/streams.js:86-101`, `hyperdrive/index.js:591-596`). A stock drive
38 fed FastCDC chunks matched the overlay to within 0.1%.
39
40## T1c — ten releases, stock drive vs overlay
41
42| Release | drive | overlay | gap |
43|---|---|---|---|
44| 1 | 360.5 MB | 360.1 MB | +0.4 |
45| 5 | 456.1 MB | 453.2 MB | +2.9 |
46| 6 | 484.8 MB | 471.4 MB | +13.4 |
47| 10 | 580.8 MB | 563.3 MB | +17.5 (3.1%) |
48
49The gap appears at release 6 — a chunk set that vanished and returned. `dedup:true`
50seeds its hash table from **only the previous entry at that path**
51(`hyperdrive/index.js:592`), so resurrected chunks are re-appended. Publish time was
52flat at ~3.6 s: the seeding re-read is O(previous release), not O(history).
53
54## T2 — does the storage seam hold?
55
56Pass-through wrapper (delegates everything, redirects nothing):
57
58```
59identical drive key / blobs key / root hash / digests true
60reopen from disk true
61replication to a stock peer true
62intercepted 111 getBlock, 111 putBlock
63```
64
65Nothing escaped the wrapper. **Pass.**
66
67## T3 — redirect, two releases
68
69```
70sameDriveKey / sameBlobsKey / sameRootHash true
71blobsLength stock 2244 virtual 2244
72input 734.0 MB
73stock store 720.8 MB
74virtual store 0.58 MB (compacted; tagged-record scheme, no side file)
75peer store 720.7 MB (stock peer replicated from the virtual drive)
76blocks 2242 redirected, 5 literal (the bee's own)
77hash failures 0
78```
79
80## T3n — ten releases, virtual
81
82| Release | blocks | store | stock equivalent |
83|---|---|---|---|
84| 1 | 1122 | 0.50 MB | 360.5 MB |
85| 5 | 1368 | 0.50 MB | 456.1 MB |
86| 10 | 1682 | **0.88 MB** | **580.8 MB** |
87
883.67 GB of releases → 0.88 MB compacted (**657×**). Publish ~2.4 s/release, *faster* than stock
89because the RocksDB block writes disappear. Pointer overhead is 36 bytes per block —
90≈0.011% of content at tier-2 chunk sizes.
91
92## T5 — real `pear install`
93
94Built a `/by-arch/linux-x64/app/` drive on virtual storage (6.29 MB payload, 120 KB
95store), seeded it on the public DHT, and ran the stock `pear` 3.0.1 CLI:
96
97```
98$ pear install pear://uimennwjwyot5cinekhb348kawufit1eq4ctyf5irurn1nfmo3so --to …
99[ Peers: 1 ] [ ⬇ 2.8MB - 2.8MB/s ]
100App: t5app Version: 1.0.0
101Target: …/t5/installed/t5app
102Installed
103```
104
105Installed file: 6,291,480 bytes, mode 755, hash identical to source, runs. Seeder
106store unchanged at 139 KB; it served 6,291,560 bytes as 89 redirected block reads
107with 0 hash failures. **Pass.**
108
109## Many small files
110
111976 files, 209.1 MB, median 2 KB, largest 11 MB (this repo's own `node_modules`),
112plus three synthetic 0-byte files to cover that case.
113
114| | stock | virtual |
115|---|---|---|
116| store (compacted) | 210.1 MB | **1.38 MB** |
117| ratio | | **152×** |
118| per file | | 1,415 bytes |
119| drive key | identical | identical |
120| readback mismatches | 0 | 0 |
121| publish | 4.2 s | 2.7 s |
122
123Blocks: 4,590 content chunks redirected, **1,950 stored literally**. The literal ones
124are the bee's own blocks (977) plus roughly one **block-map block per file** — with
125`dedup: true` hyperblobs appends an encoded block map after each file's data, and that
126map is generated metadata, not content we hold, so it correctly falls through to real
127storage.
128
129So the floor for an app tree is ~1.4 KB per file regardless of file size: bee entry +
130block-map block + tree nodes + pointer record. A 10,000-file tree costs ~14 MB.
131Small files dilute the win (152× rather than the 657× of large artifacts) because
132metadata, not bytes, becomes the cost.
133
134## truncate / clear
135
136Three files (v1 and v2 of the same path, which share blocks under `dedup:true`, plus
137an unrelated file), then `drive.clear('/app.bin')`, then a truncate of the blobs core.
138Sizes are compacted.
139
140| | stock | virtual |
141|---|---|---|
142| after publish | 6.81 MB | 0.13 MB |
143| after `clear('/app.bin')` | 6.90 MB | 0.22 MB |
144| after attempted truncate | 6.98 MB | 0.30 MB |
145| `has()` on a cleared block | false | false |
146| read the cleared entry | REQUEST_TIMEOUT | REQUEST_TIMEOUT |
147| read the untouched entry | ok | ok |
148
149**Parity: the virtual drive behaves exactly like the stock one.** Two findings, both
150upstream and both reproduced on stock:
151
1521. **`clear()` reclaims no disk.** Clearing ~4 MB of blocks left the stock store
153 *larger* (6.81 → 6.90 MB) even after `compactRange`. The corestore column family
154 sets `blobGarbageCollectionAgeCutOff: 0.25`, so blob-file space is collected lazily
155 — a plausible explanation I did not verify. Either way, "clear to reclaim space" is
156 not a thing you can rely on promptly.
157
158 This is an argument *for* virtualising: on a virtual drive, clear frees only pointer
159 records, and the bytes are freed by the overlay's own refcount and `unlink` —
160 immediately, and verifiably.
161
1622. **clear-then-truncate is not a supported sequence.** After a clear, truncating the
163 blobs core fails with `INVALID_OPERATION: Expected tree node 71 from storage, got
164 (nil)` — on stock and virtual identically. Mechanism: `clear()` deletes merkle tree
165 nodes for fully-cleared spans (`hypercore/lib/session-state.js:576-577`), and
166 truncate then needs one of them. Same-session clear-then-truncate happens to work
167 (the node is still cached); across sessions it fails. Pick one operation, not both.
168
169## Crash during append
170
171SIGKILL a publish in flight, reopen, and look for the one state that would prove the
172tagged-record scheme broken: the tree claims a block whose bytes cannot be produced.
173Eight trials (four kill points × stock and virtual), one 367 MB artifact.
174
175```
176trials 8
177killed mid-publish 7
178all stores reopened true
179tree claims a block it cannot produce 0
180verdict PASS
181```
182
183Every killed publish left `entry: none` — hyperdrive writes the bee entry only after
184the blob stream closes, so a crash can never expose a half-written file. The one trial
185that completed read back byte-identical. **Parity with stock throughout.**
186
187Two things worth noting:
188
189- **Virtual publishes get further per unit time.** At the 950 ms kill point the virtual
190 drive had committed 816 blocks to stock's 272 — the same ~3× that shows up as faster
191 publish wall-clock, because the RocksDB block writes are gone.
192- **A crashed publish leaks orphaned blocks**, and that is where the two diverge
193 sharply. Compacted, a crashed stock publish left **182.5 MB** of blocks with no entry
194 pointing at them; the virtual equivalent — further along, 816 blocks vs 560 — left
195 **0.28 MB**. Combined with `clear()` not reclaiming promptly, aborted publishes on a
196 stock drive accumulate garbage that is awkward to get rid of.
197
198Caveat: SIGKILL ends the process but the OS still flushes its page cache. This is not a
199power-loss test, and nothing here should be read as an fsync-durability claim.
200
201## Encryption
202
203Same 8 MB payload, same adapter, with and without `encryptionKey`. Sizes compacted.
204
205| | plaintext | encrypted |
206|---|---|---|
207| blocks redirected | 102 | **0** |
208| blocks stored literally | 3 | **105** |
209| store | 0.14 MB | **8.53 MB** |
210| readback | ok | ok |
211| drive key | identical | identical |
212
213**Virtualisation is lost completely under encryption, and correctness is unaffected.**
214Hypercore encrypts in `_preappend`, above the storage layer, so the bytes reaching
215`putBlock` are ciphertext with 8 bytes of prepended padding. Their hashes are not chunk
216hashes, so every block falls through to real storage — the fall-through-by-hash design
217degrades safely to a normal drive rather than corrupting anything.
218
219**It is recoverable.** The cipher is a keyed stream XOR nonced by block index and
220blinded fork (`hypercore/lib/default-encryption.js:49-69`), and the probe confirms it:
221same plaintext at the same index gives identical ciphertext, a different index gives
222different ciphertext. So an adapter holding the encryption key could decrypt in
223`putBlock` to recover the plaintext hash, and re-encrypt in `getBlock` from the stored
224plaintext chunk. Dedup survives that, because hyperblobs hashes what the *caller*
225writes — plaintext — and only storage ever sees ciphertext.
226
227Two things to get right if that is built: `CoreRX` already carries `fork`, but `CoreTX`
228does not obviously, and the fork participates in the nonce. And it hands the storage
229layer a key it currently never sees, which is a real trust-boundary change.
230
231Until then: **publish public apps unencrypted** — which is what `pear://` distribution
232is anyway — and treat encrypted private repos as a separate track.
233
234## GC / bitfield
235
236```
237host still advertises after bytes deleted TRUE ← the lie
238peer requesting it REQUEST_TIMEOUT after 5.0 s
239after core.clear(range): host advertises FALSE
240unrelated file still fetchable TRUE
241```
242
243`core.clear()` on the affected range makes the advertisement truthful again. One run of
244the probe died with an *uncaught* `REQUEST_TIMEOUT` thrown from
245`hypercore/lib/replicator.js:3371` rather than rejecting `get()`; the rerun did not
246reproduce it. Racy, but a long-lived seeder in that state could take an uncaught
247exception.
248
249### Two seeders — the lie is survivable (corrects the framing above)
250
251A (virtual) publishes, B (stock) mirrors it fully, then A's bytes are GC'd. A fresh
252client connected to **both** asks for the file.
253
254| | outcome | elapsed | blocks served by B |
255|---|---|---|---|
256| A lies (GC, no clear) | **ok** | 117–135 ms | 93–109 of ~110 |
257| A cleared the range | **ok** | 104–133 ms | 92–109 of ~110 |
258
259Four runs each; the distributions overlap, so the elapsed difference is noise, not
260signal. **Both recover, and the second seeder serves essentially all of it.**
261
262So an un-cleared GC is *not* the correctness hazard the single-seeder probe implied.
263Hypercore's replicator routes around a peer that fails to deliver, and a second source
264covers it transparently. With no second source, cleared or not, the client times out
265identically — the bytes are gone either way.
266
267Clearing after GC is therefore **hygiene, not a requirement**: it keeps advertisement
268honest and avoids wasted requests, and can be done opportunistically rather than
269atomically with the byte drop.
270
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
293## Upstream bug found
294
295A 0-byte file written with `dedup: true` cannot be read back: hyperblobs writes an
296empty block map, then `seekBlockMap` dereferences `map.blocks === null`
297(`hyperblobs/lib/streams.js:327`). Reproduced on a plain store, so it is upstream.
298Matters because app trees are full of empty files. Workaround: write 0-byte files
299without dedup.
docs/design.md +107 -0
……@@ -0,0 +1,107 @@
1# How it works
2
3## The seam
4
5`Core` obtains its per-core storage from exactly two calls (`hypercore/lib/core.js`):
6
7```js
8:162 let storage = await this.db.resumeCore(this.discoveryKey)
9:232 storage = await this.db.createCore({ ... })
10```
11
12Subclassing `CorestoreStorage` and wrapping what those two return puts us underneath
13every block read and write, with nothing above aware. `Hypercore.defaultStorage`
14accepts any object that duck-types as core storage (`isCorestoreStorage` checks for
15`setDefaultDiscoveryKey`), so a subclass instance passes straight through Corestore.
16
17Objects that construct more of themselves must be re-wrapped — `snapshot()` and
18`atomize()` — and `read()`/`write()` return wrapped RX/TX. That is the whole surface:
19five methods.
20
21**Getters must be read with `Reflect.get(target, prop)` and methods bound to the
22target, not the proxy.** Hypercore's storage objects hold state in plain properties
23that break if `this` becomes the proxy.
24
25## Block records
26
27Every stored record carries a one-byte tag:
28
29```
300x00 | <original bytes> literal — bee blocks, and anything we do not hold
310x01 | <32-byte hash> pointer — bytes live in the content store
32```
33
34Because the pointer *is* the block value, it commits in the same `CoreTX` as the tree
35node — tree and pointer land atomically — and `deleteBlock` / truncate free it with no
36extra bookkeeping. Cost: 1 byte per literal block, 33 bytes per virtualised block.
37
38Fall-through is by content hash: a block whose hash the store does not recognise is
39written literally. That is what makes the failure mode safe — an unrecognised block is
40always stored, never assumed. It is also why encryption degrades to an ordinary drive
41rather than corrupting one.
42
43## Chunks are hypercore leaves
44
45`hyper-overlay`'s chunk hash is `hypercore-crypto`'s `data()` (`chunker.js:171`), which
46is the same construction hypercore uses for a merkle **leaf**
47(`hypercore/lib/merkle-tree.js:1227`). So an existing FastCDC chunk hash *is* the leaf
48hash, and a chunk maps directly onto a block with no translation.
49
50Write files chunk-by-chunk and the block boundaries become the chunk boundaries —
51`BlobWriteStream` makes one block per `write()`. With `dedup: true` hyperblobs records
52a block map of `{index, byteLength}`, so blocks may be variable-sized and repeated.
53
54## The locations index
55
56A drive stores `index → contentHash`. Serving needs the other half,
57`contentHash → (file, offset, length)`, and `hyper-overlay`'s `FileIndex` has no such
58reverse index — it maps path → chunks and oid → chunks.
59
60Rebuilding it by re-chunking every published file is O(all bytes) on every boot, which
61is untenable for a node holding a repo's release history. So `publishTree` writes it
62once, at publish time, when both halves are already known: `locations.json` beside the
63drive, paths relative to a `root` so it survives a move and never bakes a local layout
64into a shared artifact. Roughly 90 bytes per unique chunk.
65
66It does two jobs:
67
681. **Serving** — `openDrive()` reads it and returns a drive ready to serve, with no
69 chunker involved.
702. **Republishing** — `dedup: true` seeds its hash table by reading back the *previous*
71 entry's blocks, and those reads come through the content store. Without the index
72 those chunks resolve to `null` and the write stalls on an unsettled promise.
73
74A persistent reverse index in `hyper-overlay` itself would remove the need (its issue
75#140).
76
77## Why not `pear seed`
78
79`pear seed` opens the drive **by key in PEAR's own corestore** and downloads it
80(`pear/subsystems/sidecar/ops/seed.js`):
81
82```js
83const corestore = this.sidecar.getCorestore()
84const drive = await session.add(new Hyperdrive(corestore, key))
85await replicator.join(this.sidecar.swarm, { server: true, client: true })
86drive.db.core.download({ start: 0, end: -1 })
87```
88
89To seed a virtual drive it would have to fetch every byte from us and materialise a
90full second copy — exactly the copy this package exists to avoid. It is a re-seeder,
91not a source: something must already be serving before it has anything to offer.
92
93So the origin serves its own drives, out of its own store. In a daemon that also buys
94one swarm for every repo rather than a process per link, supervision, re-announcement
95on boot, and serving through the redirect.
96
97## Compaction timing
98
99RocksDB's footprint carries the write churn of a publish — 76 MB against a 0.58 MB
100steady state, for the same publish. `publishTree` compacts in its `finally`, after the
101store closes and **before anything else opens it**.
102
103That window matters: a seeder takes an exclusive lock the moment the drive is
104announced, and a second opener gets `File descriptor could not be locked`. After that
105point the churn cannot be reclaimed without stopping the daemon.
106
107The same lock is why a republish must drop the drive first, publish, then re-announce.
docs/gaps.md +72 -0
……@@ -0,0 +1,72 @@
1# Gaps
2
3What is unproven or known-broken. Read this before relying on any of it.
4
5## Encryption defeats it entirely
6
7Hypercore encrypts in `_preappend`, above the storage layer, so `putBlock` sees
8ciphertext with 8 bytes of prepended padding whose hash is not a chunk hash. Measured:
9**0 of 105 blocks redirect**, and the store goes from 0.14 MB to 8.53 MB. Readback
10stays correct — fall-through-by-hash degrades to an ordinary drive rather than
11corrupting one.
12
13Recoverable in principle. The cipher is a keyed stream XOR nonced by block index and
14blinded fork (`hypercore/lib/default-encryption.js:49-69`), and it is deterministic per
15index, so an adapter holding the key could decrypt on write and re-encrypt on read.
16Dedup would survive, since hyperblobs hashes what the *caller* writes — plaintext — and
17only storage sees ciphertext. Two catches: `CoreTX` does not obviously carry the fork
18that participates in the nonce, and it hands the storage layer a key it never sees today.
19
20Until then: **publish public apps unencrypted**, which is what `pear://` distribution
21is anyway. Whether this is ever worth building depends on which layer owns encryption
22— if `hyper-overlay` encrypts its own content, the adapter needs no changes at all.
23
24## The seam is unsupported
25
26`hypercore-storage` has had no public pluggable-storage contract since
27`random-access-storage` was dropped. Everything here wedges into an internal shape.
28
29Mitigated, not solved, by `test/canary.mjs`, which asserts every assumption the adapter
30makes and prints resolved versions. Required checks pass against hypercore-storage
312.9.0, 3.0.2, 3.1.2 and 3.2.1 — stable across that range. `CoreRX` gained its `fork`
32field only in 3.2.x, flagged advisory since it matters only for encryption work.
33
34Run the canary on any dependency bump.
35
36## Block streams are not decoded
37
38`createBlockStream` — used by `audit.js` and `copy-prologue.js` — yields raw tagged
39records rather than decoded bytes. Anything walking blocks that way sees the pointer
40framing. Not hit in the publish/serve path, but it would surprise an audit.
41
42## Untested
43
44- **Power loss.** SIGKILL is covered and passes: eight trials, zero cases of the tree
45 claiming a block it could not produce. But SIGKILL leaves the OS page cache to flush,
46 so nothing here is an fsync-durability result.
47- **Whether a published AppImage actually launches.** Installs are verified
48 byte-identical; both test machines are headless.
49
50## Sharp edges worth knowing
51
52**0-byte files must not use `dedup: true`.** Upstream bug: hyperblobs writes an empty
53block map, then `seekBlockMap` dereferences `map.blocks === null`
54(`hyperblobs/lib/streams.js:327`). Reproduces on a plain store. `publishTree` already
55routes empty files around it; anything else writing drives must too.
56
57**Dedup only seeds from the previous entry at the same path.** A chunk that vanishes
58and returns is re-appended — worth 3.1% over ten releases against a store that dedups
59against everything. It also means the same file published at two paths is written
60twice, though both redirect to the same bytes on disk.
61
62**GC has to be paired, but not atomically.** Dropping bytes does not update the
63bitfield, so the core keeps advertising blocks it cannot produce. Measured with two
64seeders, that is survivable: the replicator routes around it and a second source serves
65in the same ~120 ms. So clearing the range is hygiene, and can be opportunistic. What
66does matter: under `dedup: true` old releases share blocks with new ones, so ranges
67must be computed from the content store's refcounts, never from a version's block range.
68
69**`clear()` reclaims no disk promptly**, and **clear-then-truncate is unsupported** —
70`clear()` prunes merkle tree nodes (`hypercore/lib/session-state.js:576-577`), so a
71later truncate fails with `Expected tree node N from storage, got (nil)`. Both
72reproduce on a stock drive.
docs/results.md +333 -0
……@@ -0,0 +1,333 @@
1# Measurements
2
3> **Measure compacted.** RocksDB's on-disk footprint includes uncompacted WAL/SST
4> churn, which is noise on a big store and dominant on a small one. The small-file
5> store below read 14.4 MB before compaction and **1.38 MB** after. The large-file
6> stores barely moved (±0.05 MB), so those figures were already steady-state.
7> `test/compact-size.mjs` does the compact-then-measure.
8
9All runs on tabertron, 2026-08-24. Source data: ten consecutive real `hypertps.pck`
10release artifacts (~367 MB each) from `~/.local/share/store/dependencies`, and
11synthetic files where noted. Chunker: `hyper-overlay/lib/chunker.js` (FastCDC,
12tier 2 → ~320 KB average chunk).
13
14## T0 — does stock Pear read chunk-sized blocks?
15
16The installed `pear` 3.0.1 bundle (`~/.config/pear/current/boot.bundle`) contains
17`node_modules/hyperblobs/lib/block-map.js` and the marker strings `Block map is too
18large`, `Unsupported block map version`, `_dedup`, `getBlockMap`. So the shipped
19reader supports block maps and variable-size blocks. **Pass.**
20
21## T1 — storage cost, one release pair
22
23Two consecutive releases, 367.06 MB each, into one drive at the same path.
24
25| Arm | after v1 | after v2 | v2 delta |
26|---|---|---|---|
27| overlay (control) | 360.1 MB | 378.3 MB | 18.2 MB |
28| hyperdrive, no dedup | 368.3 MB | 736.4 MB | 368.1 MB |
29| hyperdrive, `dedup:true`, fixed 64 KB | 367.6 MB | 488.7 MB | 121.2 MB |
30| hyperdrive, `dedup:true`, FastCDC | 360.5 MB | 378.7 MB | 18.2 MB |
31
32Two corrections to earlier assumptions:
33
34- RocksDB per-byte overhead is **0.3%**, not a multiplier (368.3 MB stored for
35 367.06 MB in). The corestore column family enables blob files with GC.
36- Hyperblobs **does** dedup, at block level, via `{dedup:true}` + block maps
37 (`hyperblobs/lib/streams.js:86-101`, `hyperdrive/index.js:591-596`). A stock drive
38 fed FastCDC chunks matched the overlay to within 0.1%.
39
40## T1c — ten releases, stock drive vs overlay
41
42| Release | drive | overlay | gap |
43|---|---|---|---|
44| 1 | 360.5 MB | 360.1 MB | +0.4 |
45| 5 | 456.1 MB | 453.2 MB | +2.9 |
46| 6 | 484.8 MB | 471.4 MB | +13.4 |
47| 10 | 580.8 MB | 563.3 MB | +17.5 (3.1%) |
48
49The gap appears at release 6 — a chunk set that vanished and returned. `dedup:true`
50seeds its hash table from **only the previous entry at that path**
51(`hyperdrive/index.js:592`), so resurrected chunks are re-appended. Publish time was
52flat at ~3.6 s: the seeding re-read is O(previous release), not O(history).
53
54## T2 — does the storage seam hold?
55
56Pass-through wrapper (delegates everything, redirects nothing):
57
58```
59identical drive key / blobs key / root hash / digests true
60reopen from disk true
61replication to a stock peer true
62intercepted 111 getBlock, 111 putBlock
63```
64
65Nothing escaped the wrapper. **Pass.**
66
67## T3 — redirect, two releases
68
69```
70sameDriveKey / sameBlobsKey / sameRootHash true
71blobsLength stock 2244 virtual 2244
72input 734.0 MB
73stock store 720.8 MB
74virtual store 0.58 MB (compacted; tagged-record scheme, no side file)
75peer store 720.7 MB (stock peer replicated from the virtual drive)
76blocks 2242 redirected, 5 literal (the bee's own)
77hash failures 0
78```
79
80## T3n — ten releases, virtual
81
82| Release | blocks | store | stock equivalent |
83|---|---|---|---|
84| 1 | 1122 | 0.50 MB | 360.5 MB |
85| 5 | 1368 | 0.50 MB | 456.1 MB |
86| 10 | 1682 | **0.88 MB** | **580.8 MB** |
87
883.67 GB of releases → 0.88 MB compacted (**657×**). Publish ~2.4 s/release, *faster* than stock
89because the RocksDB block writes disappear. Pointer overhead is 36 bytes per block —
90≈0.011% of content at tier-2 chunk sizes.
91
92## T5 — real `pear install`
93
94Built a `/by-arch/linux-x64/app/` drive on virtual storage (6.29 MB payload, 120 KB
95store), seeded it on the public DHT, and ran the stock `pear` 3.0.1 CLI:
96
97```
98$ pear install pear://uimennwjwyot5cinekhb348kawufit1eq4ctyf5irurn1nfmo3so --to …
99[ Peers: 1 ] [ ⬇ 2.8MB - 2.8MB/s ]
100App: t5app Version: 1.0.0
101Target: …/t5/installed/t5app
102Installed
103```
104
105Installed file: 6,291,480 bytes, mode 755, hash identical to source, runs. Seeder
106store unchanged at 139 KB; it served 6,291,560 bytes as 89 redirected block reads
107with 0 hash failures. **Pass.**
108
109## Many small files
110
111976 files, 209.1 MB, median 2 KB, largest 11 MB (this repo's own `node_modules`),
112plus three synthetic 0-byte files to cover that case.
113
114| | stock | virtual |
115|---|---|---|
116| store (compacted) | 210.1 MB | **1.38 MB** |
117| ratio | | **152×** |
118| per file | | 1,415 bytes |
119| drive key | identical | identical |
120| readback mismatches | 0 | 0 |
121| publish | 4.2 s | 2.7 s |
122
123Blocks: 4,590 content chunks redirected, **1,950 stored literally**. The literal ones
124are the bee's own blocks (977) plus roughly one **block-map block per file** — with
125`dedup: true` hyperblobs appends an encoded block map after each file's data, and that
126map is generated metadata, not content we hold, so it correctly falls through to real
127storage.
128
129So the floor for an app tree is ~1.4 KB per file regardless of file size: bee entry +
130block-map block + tree nodes + pointer record. A 10,000-file tree costs ~14 MB.
131Small files dilute the win (152× rather than the 657× of large artifacts) because
132metadata, not bytes, becomes the cost.
133
134## truncate / clear
135
136Three files (v1 and v2 of the same path, which share blocks under `dedup:true`, plus
137an unrelated file), then `drive.clear('/app.bin')`, then a truncate of the blobs core.
138Sizes are compacted.
139
140| | stock | virtual |
141|---|---|---|
142| after publish | 6.81 MB | 0.13 MB |
143| after `clear('/app.bin')` | 6.90 MB | 0.22 MB |
144| after attempted truncate | 6.98 MB | 0.30 MB |
145| `has()` on a cleared block | false | false |
146| read the cleared entry | REQUEST_TIMEOUT | REQUEST_TIMEOUT |
147| read the untouched entry | ok | ok |
148
149**Parity: the virtual drive behaves exactly like the stock one.** Two findings, both
150upstream and both reproduced on stock:
151
1521. **`clear()` reclaims no disk.** Clearing ~4 MB of blocks left the stock store
153 *larger* (6.81 → 6.90 MB) even after `compactRange`. The corestore column family
154 sets `blobGarbageCollectionAgeCutOff: 0.25`, so blob-file space is collected lazily
155 — a plausible explanation I did not verify. Either way, "clear to reclaim space" is
156 not a thing you can rely on promptly.
157
158 This is an argument *for* virtualising: on a virtual drive, clear frees only pointer
159 records, and the bytes are freed by the overlay's own refcount and `unlink` —
160 immediately, and verifiably.
161
1622. **clear-then-truncate is not a supported sequence.** After a clear, truncating the
163 blobs core fails with `INVALID_OPERATION: Expected tree node 71 from storage, got
164 (nil)` — on stock and virtual identically. Mechanism: `clear()` deletes merkle tree
165 nodes for fully-cleared spans (`hypercore/lib/session-state.js:576-577`), and
166 truncate then needs one of them. Same-session clear-then-truncate happens to work
167 (the node is still cached); across sessions it fails. Pick one operation, not both.
168
169## Crash during append
170
171SIGKILL a publish in flight, reopen, and look for the one state that would prove the
172tagged-record scheme broken: the tree claims a block whose bytes cannot be produced.
173Eight trials (four kill points × stock and virtual), one 367 MB artifact.
174
175```
176trials 8
177killed mid-publish 7
178all stores reopened true
179tree claims a block it cannot produce 0
180verdict PASS
181```
182
183Every killed publish left `entry: none` — hyperdrive writes the bee entry only after
184the blob stream closes, so a crash can never expose a half-written file. The one trial
185that completed read back byte-identical. **Parity with stock throughout.**
186
187Two things worth noting:
188
189- **Virtual publishes get further per unit time.** At the 950 ms kill point the virtual
190 drive had committed 816 blocks to stock's 272 — the same ~3× that shows up as faster
191 publish wall-clock, because the RocksDB block writes are gone.
192- **A crashed publish leaks orphaned blocks**, and that is where the two diverge
193 sharply. Compacted, a crashed stock publish left **182.5 MB** of blocks with no entry
194 pointing at them; the virtual equivalent — further along, 816 blocks vs 560 — left
195 **0.28 MB**. Combined with `clear()` not reclaiming promptly, aborted publishes on a
196 stock drive accumulate garbage that is awkward to get rid of.
197
198Caveat: SIGKILL ends the process but the OS still flushes its page cache. This is not a
199power-loss test, and nothing here should be read as an fsync-durability claim.
200
201## Encryption
202
203Same 8 MB payload, same adapter, with and without `encryptionKey`. Sizes compacted.
204
205| | plaintext | encrypted |
206|---|---|---|
207| blocks redirected | 102 | **0** |
208| blocks stored literally | 3 | **105** |
209| store | 0.14 MB | **8.53 MB** |
210| readback | ok | ok |
211| drive key | identical | identical |
212
213**Virtualisation is lost completely under encryption, and correctness is unaffected.**
214Hypercore encrypts in `_preappend`, above the storage layer, so the bytes reaching
215`putBlock` are ciphertext with 8 bytes of prepended padding. Their hashes are not chunk
216hashes, so every block falls through to real storage — the fall-through-by-hash design
217degrades safely to a normal drive rather than corrupting anything.
218
219**It is recoverable.** The cipher is a keyed stream XOR nonced by block index and
220blinded fork (`hypercore/lib/default-encryption.js:49-69`), and the probe confirms it:
221same plaintext at the same index gives identical ciphertext, a different index gives
222different ciphertext. So an adapter holding the encryption key could decrypt in
223`putBlock` to recover the plaintext hash, and re-encrypt in `getBlock` from the stored
224plaintext chunk. Dedup survives that, because hyperblobs hashes what the *caller*
225writes — plaintext — and only storage ever sees ciphertext.
226
227Two things to get right if that is built: `CoreRX` already carries `fork`, but `CoreTX`
228does not obviously, and the fork participates in the nonce. And it hands the storage
229layer a key it currently never sees, which is a real trust-boundary change.
230
231Until then: **publish public apps unencrypted** — which is what `pear://` distribution
232is anyway — and treat encrypted private repos as a separate track.
233
234## GC / bitfield
235
236```
237host still advertises after bytes deleted TRUE ← the lie
238peer requesting it REQUEST_TIMEOUT after 5.0 s
239after core.clear(range): host advertises FALSE
240unrelated file still fetchable TRUE
241```
242
243`core.clear()` on the affected range makes the advertisement truthful again. One run of
244the probe died with an *uncaught* `REQUEST_TIMEOUT` thrown from
245`hypercore/lib/replicator.js:3371` rather than rejecting `get()`; the rerun did not
246reproduce it. Racy, but a long-lived seeder in that state could take an uncaught
247exception.
248
249### Two seeders — the lie is survivable (corrects the framing above)
250
251A (virtual) publishes, B (stock) mirrors it fully, then A's bytes are GC'd. A fresh
252client connected to **both** asks for the file.
253
254| | outcome | elapsed | blocks served by B |
255|---|---|---|---|
256| A lies (GC, no clear) | **ok** | 117–135 ms | 93–109 of ~110 |
257| A cleared the range | **ok** | 104–133 ms | 92–109 of ~110 |
258
259Four runs each; the distributions overlap, so the elapsed difference is noise, not
260signal. **Both recover, and the second seeder serves essentially all of it.**
261
262So an un-cleared GC is *not* the correctness hazard the single-seeder probe implied.
263Hypercore's replicator routes around a peer that fails to deliver, and a second source
264covers it transparently. With no second source, cleared or not, the client times out
265identically — the bytes are gone either way.
266
267Clearing after GC is therefore **hygiene, not a requirement**: it keeps advertisement
268honest and avoids wasted requests, and can be done opportunistically rather than
269atomically with the byte drop.
270
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
293
294## End to end, from CI (2026-08-24)
295
296`ifsrenderer-linux`, opted in with `vdrive: true` in its `.pear-git/ci.yaml`, built
297and published by pear-git-v3, seeded by the daemon, installed by the stock `pear`
298CLI on a second machine over the public DHT.
299
300| | release 1 | release 2 |
301|---|---|---|
302| CI build | 76 s | 80 s |
303| blocks | 1835 | 2478 |
304| new pointer blocks | 1829 | **637** |
305| prior locations loaded | 0 | 1216 |
306| drive store (compacted) | 0.58 MB | 5.1 MB* |
307| installed sha | `87b4487d…` | `7bf5023c…` |
308| link | \multicolumn — unchanged across both | |
309
310\* the live drive was written by 0.1.229, before compaction moved into publishTree;
311the same publish into a fresh store compacts to 0.58 MB.
312
313284 MB of release artifacts across three versions, served by one drive under one
314stable `pear://` link. Both installs byte-identical to what CI built, mode 755, with
315`pear`'s Linux desktop-entry and icon install completing.
316
317The AppImage result also corrects an earlier generalisation in this document: three
318*different* 49 MB AppImage builds deduped at 97.7% (614 chunks, then 14, then 12).
319Compression does not zero dedup — **stream** compression does. gzip over a tarball is
320one continuous stream, so a single byte shifts everything after it; a squashfs (and so
321an AppImage) compresses per block, so a change disturbs only the blocks it touches.
322
323Note that the claim "git bundles do not dedup because they are compressed", made
324elsewhere in our notes, is an *inference* from the tarball measurement and has never
325been measured on bundles.
326
327## Upstream bug found
328
329A 0-byte file written with `dedup: true` cannot be read back: hyperblobs writes an
330empty block map, then `seekBlockMap` dereferences `map.blocks === null`
331(`hyperblobs/lib/streams.js:327`). Reproduced on a plain store, so it is upstream.
332Matters because app trees are full of empty files. Workaround: write 0-byte files
333without dedup.