OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / ccb2ae0

0.0.45: the mirror walks to level 2 — the files a record names
A record's own bytes are one oid, carried in the log. The pictures and files inside it are more oids, carried in its markdown. Mirroring only the first leaves a post you can read whose image will not load — which is the outage this module exists for, one level down. Following means holding what somebody published, and a picture is part of what they published. ⚠ AND THERE IS ONE STORE, NOT TWO. files.js line 3: "There is NO blob store, and nothing new is being invented here." A record and an image are both registerFile/fetchFile by content hash in the same hyper-overlay. I had split them into two systems because they sit in two DIRECTORIES — filesystem layout, not architecture — and asked which one the mirror should follow. It was never a question: record-vocabulary §3.5 settled media as favela://blob/<oid> over the same overlay before any of this was written. Uses favela-markdown's embeds(), which walks the real markdown tree, so the mirror sees exactly what a renderer would rather than a regex's idea of it. It covers linked files as well as images. ⛔ A file we cannot fetch does NOT undo the record. The record is held and readable; the picture is missing, which is the honest state one level down, and the next pass retries it. stats() counts files separately from records. ★ AND THIS IS WHAT MAKES RETENTION A REAL QUESTION. Measured on tabertron: 56 records across 3 feeds is 316K, largest record 1305 bytes — level 1 alone needs no budget at any plausible scale, and the design doc's claim that "a handset cannot hold everything" was asserted without measuring. Level 2 on the same node is 17M. The depth of the walk is what determines size, by roughly 50x, and that is the input to eviction — not device class. favela-peer 337/337 tests, 802/802 asserts (was 334/797) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccb2ae01a2aa9c4f1a306bce885904cf08fcb961
by plankatron <fleeky@gmail.com> · 2026-09-08T19:46:59.000Z

Changed files (5) +125 -27

.pgh-lock.json +23 -23
……@@ -1,9 +1,9 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.43",
3 "version": "0.0.44",
44 "runtime": "bare",
5 "rootTreeHash": "91f004fdec4b38dd4c751e1e99702893b60b2ba5010e4b92015a945fb1803f17",
6 "publishedAt": "2026-09-08T19:00:06.696Z",
5 "rootTreeHash": "c4f89bd25a183aeab4d047c0496d479dbd3c948142e069c62ee78f8df8e9e0cc",
6 "publishedAt": "2026-09-08T19:24:14.838Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -3017,7 +3017,7 @@
30173017 }
30183018 ],
30193019 "reason": "version-splits only: 0 code-differs",
3020 "acceptedAt": "2026-09-08T19:00:06.709Z"
3020 "acceptedAt": "2026-09-08T19:24:14.856Z"
30213021 },
30223022 {
30233023 "type": "VERSION_CONFLICT",
……@@ -3067,7 +3067,7 @@
30673067 }
30683068 ],
30693069 "reason": "version-splits only: 0 code-differs",
3070 "acceptedAt": "2026-09-08T19:00:06.709Z"
3070 "acceptedAt": "2026-09-08T19:24:14.856Z"
30713071 },
30723072 {
30733073 "type": "VERSION_CONFLICT",
……@@ -3082,7 +3082,7 @@
30823082 }
30833083 ],
30843084 "reason": "version-splits only: 0 code-differs",
3085 "acceptedAt": "2026-09-08T19:00:06.709Z"
3085 "acceptedAt": "2026-09-08T19:24:14.856Z"
30863086 },
30873087 {
30883088 "type": "VERSION_CONFLICT",
……@@ -3107,7 +3107,7 @@
31073107 }
31083108 ],
31093109 "reason": "version-splits only: 0 code-differs",
3110 "acceptedAt": "2026-09-08T19:00:06.709Z"
3110 "acceptedAt": "2026-09-08T19:24:14.856Z"
31113111 },
31123112 {
31133113 "type": "VERSION_CONFLICT",
……@@ -3142,7 +3142,7 @@
31423142 }
31433143 ],
31443144 "reason": "version-splits only: 0 code-differs",
3145 "acceptedAt": "2026-09-08T19:00:06.709Z"
3145 "acceptedAt": "2026-09-08T19:24:14.856Z"
31463146 },
31473147 {
31483148 "type": "VERSION_CONFLICT",
……@@ -3157,7 +3157,7 @@
31573157 }
31583158 ],
31593159 "reason": "version-splits only: 0 code-differs",
3160 "acceptedAt": "2026-09-08T19:00:06.709Z"
3160 "acceptedAt": "2026-09-08T19:24:14.856Z"
31613161 },
31623162 {
31633163 "type": "VERSION_CONFLICT",
……@@ -3172,7 +3172,7 @@
31723172 }
31733173 ],
31743174 "reason": "version-splits only: 0 code-differs",
3175 "acceptedAt": "2026-09-08T19:00:06.709Z"
3175 "acceptedAt": "2026-09-08T19:24:14.856Z"
31763176 },
31773177 {
31783178 "type": "VERSION_CONFLICT",
……@@ -3187,7 +3187,7 @@
31873187 }
31883188 ],
31893189 "reason": "version-splits only: 0 code-differs",
3190 "acceptedAt": "2026-09-08T19:00:06.709Z"
3190 "acceptedAt": "2026-09-08T19:24:14.856Z"
31913191 },
31923192 {
31933193 "type": "VERSION_CONFLICT",
……@@ -3202,7 +3202,7 @@
32023202 }
32033203 ],
32043204 "reason": "version-splits only: 0 code-differs",
3205 "acceptedAt": "2026-09-08T19:00:06.709Z"
3205 "acceptedAt": "2026-09-08T19:24:14.856Z"
32063206 },
32073207 {
32083208 "type": "VERSION_CONFLICT",
……@@ -3232,7 +3232,7 @@
32323232 }
32333233 ],
32343234 "reason": "version-splits only: 0 code-differs",
3235 "acceptedAt": "2026-09-08T19:00:06.709Z"
3235 "acceptedAt": "2026-09-08T19:24:14.856Z"
32363236 },
32373237 {
32383238 "type": "VERSION_CONFLICT",
……@@ -3247,7 +3247,7 @@
32473247 }
32483248 ],
32493249 "reason": "version-splits only: 0 code-differs",
3250 "acceptedAt": "2026-09-08T19:00:06.709Z"
3250 "acceptedAt": "2026-09-08T19:24:14.856Z"
32513251 },
32523252 {
32533253 "type": "VERSION_CONFLICT",
……@@ -3277,7 +3277,7 @@
32773277 }
32783278 ],
32793279 "reason": "version-splits only: 0 code-differs",
3280 "acceptedAt": "2026-09-08T19:00:06.709Z"
3280 "acceptedAt": "2026-09-08T19:24:14.856Z"
32813281 },
32823282 {
32833283 "type": "VERSION_CONFLICT",
……@@ -3307,7 +3307,7 @@
33073307 }
33083308 ],
33093309 "reason": "version-splits only: 0 code-differs",
3310 "acceptedAt": "2026-09-08T19:00:06.709Z"
3310 "acceptedAt": "2026-09-08T19:24:14.856Z"
33113311 },
33123312 {
33133313 "type": "VERSION_CONFLICT",
……@@ -3322,7 +3322,7 @@
33223322 }
33233323 ],
33243324 "reason": "version-splits only: 0 code-differs",
3325 "acceptedAt": "2026-09-08T19:00:06.709Z"
3325 "acceptedAt": "2026-09-08T19:24:14.856Z"
33263326 },
33273327 {
33283328 "type": "VERSION_CONFLICT",
……@@ -3337,7 +3337,7 @@
33373337 }
33383338 ],
33393339 "reason": "version-splits only: 0 code-differs",
3340 "acceptedAt": "2026-09-08T19:00:06.709Z"
3340 "acceptedAt": "2026-09-08T19:24:14.856Z"
33413341 },
33423342 {
33433343 "type": "VERSION_CONFLICT",
……@@ -3367,7 +3367,7 @@
33673367 }
33683368 ],
33693369 "reason": "version-splits only: 0 code-differs",
3370 "acceptedAt": "2026-09-08T19:00:06.709Z"
3370 "acceptedAt": "2026-09-08T19:24:14.856Z"
33713371 },
33723372 {
33733373 "type": "VERSION_CONFLICT",
……@@ -3387,7 +3387,7 @@
33873387 }
33883388 ],
33893389 "reason": "version-splits only: 0 code-differs",
3390 "acceptedAt": "2026-09-08T19:00:06.709Z"
3390 "acceptedAt": "2026-09-08T19:24:14.856Z"
33913391 },
33923392 {
33933393 "type": "VERSION_CONFLICT",
……@@ -3412,7 +3412,7 @@
34123412 }
34133413 ],
34143414 "reason": "version-splits only: 0 code-differs",
3415 "acceptedAt": "2026-09-08T19:00:06.709Z"
3415 "acceptedAt": "2026-09-08T19:24:14.856Z"
34163416 },
34173417 {
34183418 "type": "VERSION_CONFLICT",
……@@ -3427,7 +3427,7 @@
34273427 }
34283428 ],
34293429 "reason": "version-splits only: 0 code-differs",
3430 "acceptedAt": "2026-09-08T19:00:06.709Z"
3430 "acceptedAt": "2026-09-08T19:24:14.856Z"
34313431 },
34323432 {
34333433 "type": "VERSION_CONFLICT",
……@@ -3442,7 +3442,7 @@
34423442 }
34433443 ],
34443444 "reason": "version-splits only: 0 code-differs",
3445 "acceptedAt": "2026-09-08T19:00:06.709Z"
3445 "acceptedAt": "2026-09-08T19:24:14.856Z"
34463446 }
34473447 ]
34483448}
lib/mirror.js +35 -3
……@@ -23,6 +23,8 @@
2323//
2424// Design: hyper-social/docs/HOLDING-AND-TELLING.md
2525
26import { embeds } from 'favela-markdown/lib/render.js'
27
2628const DEFAULT_CONCURRENCY = 4
2729
2830/**
……@@ -33,7 +35,7 @@ const DEFAULT_CONCURRENCY = 4
3335 * than what exists. What is unbounded is the file count, and a freshly followed
3436 * feed is all of them at once.
3537 */
36export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, debounceMs = 2000, log = () => {} }) {
38export function createMirror ({ feeds, files = null, concurrency = DEFAULT_CONCURRENCY, debounceMs = 2000, log = () => {} }) {
3739 if (typeof feeds !== 'function') throw new Error('createMirror: feeds() is required')
3840
3941 let stopped = false
……@@ -43,7 +45,7 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
4345 // ⛔ Counted per TRIGGER so a dead hook is OBSERVABLE. 0.0.40's watcher never fired and nothing
4446 // on a running node could say so — the only evidence was how LONG a record took to appear, which
4547 // is inference. These counters are what turned that into a measurement.
46 const fired = { append: 0, connection: 0, boot: 0 }
48 const fired = { append: 0, connection: 0, boot: 0, files: 0 }
4749 const bases = []
4850 const hooked = new Map()
4951
……@@ -70,6 +72,36 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
7072 }
7173 }
7274
75 /**
76 * ⛔ LEVEL 2 — the files a record NAMES.
77 *
78 * A record's own bytes are one oid, carried in the log. The pictures and files inside it are more
79 * oids, carried in its markdown. Mirroring only the first leaves a post you can read whose image
80 * will not load — the same failure as the outage this module exists for, one level down.
81 * Following means holding what somebody published, and a picture is part of what they published.
82 *
83 * ⚠ Both levels are the SAME store. files.js: "There is NO blob store" — a record and an image
84 * are both registerFile/fetchFile by content hash in hyper-overlay. There is one store here.
85 *
86 * `embeds()` is favela-markdown's real parser over the markdown tree, so this sees exactly what a
87 * renderer would and never a regex's idea of it. A file we cannot fetch does NOT undo the record:
88 * the record is held and readable, and the picture is missing — which is the honest state one
89 * level down, and the next pass retries it.
90 */
91 async function mirrorFiles (record) {
92 if (!files || typeof files.get !== 'function') return
93 const body = record && record.body
94 if (!body) return
95 let refs
96 try { refs = embeds(body) } catch { return }
97 for (const e of refs) {
98 if (stopped) return
99 try {
100 if (await files.get(e.oid)) fired.files++
101 } catch { /* missing, not fatal — the record stands */ }
102 }
103 }
104
73105 async function mirrorFeed (key, feed) {
74106 // ⛔ `pointers()` and not `list()` or `unresolved()`. Both of those RESOLVE every record, which
75107 // means both of them fetch — unbounded, all at once. Enumerating without fetching is what lets
……@@ -113,7 +145,7 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
113145 // is gone: the record stays unresolved, the surface says so, and the next pass retries.
114146 const got = await feed.store.ensureLocal(r.collection, r.name, r.pointer && r.pointer.$oid)
115147 if (got == null) failed++
116 else fetched++
148 else { fetched++; await mirrorFiles(got) }
117149 } catch {
118150 failed++
119151 }
lib/node.js +4 -0
……@@ -225,6 +225,10 @@ export class FavelaNode {
225225 // `following()` rather than every readable feed. `peer.timeline` draws the same line for the
226226 // same reason. See hyper-social/docs/HOLDING-AND-TELLING.md.
227227 this._mirror = createMirror({
228 // ⛔ LEVEL 2. A record's bytes are one oid in the log; the files it NAMES are more oids inside
229 // its markdown, in the SAME overlay. Without this a mirrored post reads fine and its picture
230 // will not load — the outage this exists for, one level down.
231 files: this.files,
228232 feeds: () => {
229233 const out = []
230234 for (const [name, feed] of this.peer.feeds) {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.44",
3 "version": "0.0.45",
44 "description": "The favela daemon — feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/mirror.test.js +62 -0
……@@ -266,3 +266,65 @@ test('★★ a peer connecting retries what could not be fetched', async (t) =>
266266 t.is(m.stats().connection, 1, 'and the connection trigger is what did it')
267267})
268268
269
270// ⛔ LEVEL 2. A record's own bytes are one oid in the log; the pictures and files it names are more
271// oids, inside its markdown. Mirroring only the first leaves a post you can read with an image that
272// will not load — the same failure as §0's outage, one level down. Following means holding what
273// somebody published, and a picture is part of what they published.
274//
275// ⚠ Both levels are the SAME hyper-overlay: files.js says "There is NO blob store", and a record
276// and an image are both registerFile/fetchFile by content hash. There is one store here, not two.
277test('★★ it mirrors the files a record names, not just the record', async (t) => {
278 const gotOids = []
279 const body = 'look\n\n![a cat](favela://blob/' + 'a'.repeat(32) + ')\n\n' +
280 '[the pdf](favela://blob/' + 'b'.repeat(32) + ')\n'
281 const feed = {
282 async update () {},
283 async pointers () { return [{ key: 'post/p1', collection: 'post', name: 'p1', pointer: { $oid: 'rec' } }] },
284 store: {
285 async get () { return null },
286 async ensureLocal () { return { frontmatter: {}, body } }
287 }
288 }
289 const files = { async get (oid) { gotOids.push(oid); return { path: '/x', mimeType: 'image/png', size: 1 } } }
290
291 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], files, log: () => {} })
292 await m.pass()
293
294 t.alike(gotOids.sort(), ['a'.repeat(32), 'b'.repeat(32)], 'the image AND the linked file')
295 t.is(m.stats().files, 2, 'counted separately from records')
296})
297
298test('★ a record naming no files costs no extra fetches', async (t) => {
299 let calls = 0
300 const feed = {
301 async update () {},
302 async pointers () { return [{ key: 'post/p1', collection: 'post', name: 'p1', pointer: { $oid: 'r' } }] },
303 store: { async get () { return null }, async ensureLocal () { return { frontmatter: {}, body: 'just words' } } }
304 }
305 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], files: { async get () { calls++; return null } }, log: () => {} })
306 await m.pass()
307 t.is(calls, 0)
308})
309
310// ⛔ A file we cannot fetch must not fail the record that named it. The record is already held and
311// readable; the picture is simply missing, which is the honest UNRESOLVED state one level down.
312test('⛔ an unfetchable file does not undo the record', async (t) => {
313 const feed = {
314 async update () {},
315 async pointers () { return [{ key: 'post/p1', collection: 'post', name: 'p1', pointer: { $oid: 'r' } }] },
316 store: {
317 async get () { return null },
318 async ensureLocal () { return { frontmatter: {}, body: '![x](favela://blob/' + 'c'.repeat(32) + ')' } }
319 }
320 }
321 const m = createMirror({
322 feeds: () => [{ key: 'f1', feed }],
323 files: { async get () { throw new Error('no holder online') } },
324 log: () => {}
325 })
326
327 const r = await m.pass()
328 t.is(r.fetched, 1, 'the record still counts as mirrored')
329 t.is(m.stats().files, 0, 'and the file did not')
330})