| … | … | @@ -1,9 +1,9 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.39", | |
| 3 | "version": "0.0.40", | |
| 4 | 4 | "runtime": "bare", |
| 5 | "rootTreeHash": "04e36fead45d65dcfec11d3254cf361cc274c79741d8fc871300d463128ac69d", | |
| 6 | "publishedAt": "2026-09-08T18:13:24.596Z", | |
| 5 | "rootTreeHash": "8a28c6cd53e3085fcb2a09894d5c6507c15fdc81a38459939b8823a2f725c9a2", | |
| 6 | "publishedAt": "2026-09-08T18:20:39.804Z", | |
| 7 | 7 | "deps": [ |
| 8 | 8 | { |
| 9 | 9 | "name": "@hyperswarm/secret-stream", |
| … | … | @@ -3017,7 +3017,7 @@ |
| 3017 | 3017 | } |
| 3018 | 3018 | ], |
| 3019 | 3019 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3020 | "acceptedAt": "2026-09-08T18:13:24.612Z" | |
| 3020 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3021 | 3021 | }, |
| 3022 | 3022 | { |
| 3023 | 3023 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3067,7 +3067,7 @@ |
| 3067 | 3067 | } |
| 3068 | 3068 | ], |
| 3069 | 3069 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3070 | "acceptedAt": "2026-09-08T18:13:24.612Z" | |
| 3070 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3071 | 3071 | }, |
| 3072 | 3072 | { |
| 3073 | 3073 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3082,7 +3082,7 @@ |
| 3082 | 3082 | } |
| 3083 | 3083 | ], |
| 3084 | 3084 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3085 | "acceptedAt": "2026-09-08T18:13:24.612Z" | |
| 3085 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3086 | 3086 | }, |
| 3087 | 3087 | { |
| 3088 | 3088 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3107,7 +3107,7 @@ |
| 3107 | 3107 | } |
| 3108 | 3108 | ], |
| 3109 | 3109 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3110 | "acceptedAt": "2026-09-08T18:13:24.612Z" | |
| 3110 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3111 | 3111 | }, |
| 3112 | 3112 | { |
| 3113 | 3113 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3142,7 +3142,7 @@ |
| 3142 | 3142 | } |
| 3143 | 3143 | ], |
| 3144 | 3144 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3145 | "acceptedAt": "2026-09-08T18:13:24.612Z" | |
| 3145 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3146 | 3146 | }, |
| 3147 | 3147 | { |
| 3148 | 3148 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3157,7 +3157,7 @@ |
| 3157 | 3157 | } |
| 3158 | 3158 | ], |
| 3159 | 3159 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3160 | "acceptedAt": "2026-09-08T18:13:24.612Z" | |
| 3160 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3161 | 3161 | }, |
| 3162 | 3162 | { |
| 3163 | 3163 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3172,7 +3172,7 @@ |
| 3172 | 3172 | } |
| 3173 | 3173 | ], |
| 3174 | 3174 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3175 | "acceptedAt": "2026-09-08T18:13:24.612Z" | |
| 3175 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3176 | 3176 | }, |
| 3177 | 3177 | { |
| 3178 | 3178 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3187,7 +3187,7 @@ |
| 3187 | 3187 | } |
| 3188 | 3188 | ], |
| 3189 | 3189 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3190 | "acceptedAt": "2026-09-08T18:13:24.612Z" | |
| 3190 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3191 | 3191 | }, |
| 3192 | 3192 | { |
| 3193 | 3193 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3202,7 +3202,7 @@ |
| 3202 | 3202 | } |
| 3203 | 3203 | ], |
| 3204 | 3204 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3205 | "acceptedAt": "2026-09-08T18:13:24.612Z" | |
| 3205 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3206 | 3206 | }, |
| 3207 | 3207 | { |
| 3208 | 3208 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3232,7 +3232,7 @@ |
| 3232 | 3232 | } |
| 3233 | 3233 | ], |
| 3234 | 3234 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3235 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3235 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3236 | 3236 | }, |
| 3237 | 3237 | { |
| 3238 | 3238 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3247,7 +3247,7 @@ |
| 3247 | 3247 | } |
| 3248 | 3248 | ], |
| 3249 | 3249 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3250 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3250 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3251 | 3251 | }, |
| 3252 | 3252 | { |
| 3253 | 3253 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3277,7 +3277,7 @@ |
| 3277 | 3277 | } |
| 3278 | 3278 | ], |
| 3279 | 3279 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3280 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3280 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3281 | 3281 | }, |
| 3282 | 3282 | { |
| 3283 | 3283 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3307,7 +3307,7 @@ |
| 3307 | 3307 | } |
| 3308 | 3308 | ], |
| 3309 | 3309 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3310 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3310 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3311 | 3311 | }, |
| 3312 | 3312 | { |
| 3313 | 3313 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3322,7 +3322,7 @@ |
| 3322 | 3322 | } |
| 3323 | 3323 | ], |
| 3324 | 3324 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3325 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3325 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3326 | 3326 | }, |
| 3327 | 3327 | { |
| 3328 | 3328 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3337,7 +3337,7 @@ |
| 3337 | 3337 | } |
| 3338 | 3338 | ], |
| 3339 | 3339 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3340 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3340 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3341 | 3341 | }, |
| 3342 | 3342 | { |
| 3343 | 3343 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3367,7 +3367,7 @@ |
| 3367 | 3367 | } |
| 3368 | 3368 | ], |
| 3369 | 3369 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3370 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3370 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3371 | 3371 | }, |
| 3372 | 3372 | { |
| 3373 | 3373 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3387,7 +3387,7 @@ |
| 3387 | 3387 | } |
| 3388 | 3388 | ], |
| 3389 | 3389 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3390 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3390 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3391 | 3391 | }, |
| 3392 | 3392 | { |
| 3393 | 3393 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3412,7 +3412,7 @@ |
| 3412 | 3412 | } |
| 3413 | 3413 | ], |
| 3414 | 3414 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3415 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3415 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3416 | 3416 | }, |
| 3417 | 3417 | { |
| 3418 | 3418 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3427,7 +3427,7 @@ |
| 3427 | 3427 | } |
| 3428 | 3428 | ], |
| 3429 | 3429 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3430 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3430 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3431 | 3431 | }, |
| 3432 | 3432 | { |
| 3433 | 3433 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3442,7 +3442,7 @@ |
| 3442 | 3442 | } |
| 3443 | 3443 | ], |
| 3444 | 3444 | "reason": "version-splits only: dry-run reports 0 code-differs", |
| 3445 | "acceptedAt": "2026-09-08T18:13:24.613Z" | |
| 3445 | "acceptedAt": "2026-09-08T18:20:39.817Z" | |
| 3446 | 3446 | } |
| 3447 | 3447 | ] |
| 3448 | 3448 | } |
| … | … | @@ -39,6 +39,7 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun |
| 39 | 39 | let stopped = false |
| 40 | 40 | let running = false |
| 41 | 41 | let timer = null |
| 42 | let interval = null | |
| 42 | 43 | const watched = [] |
| 43 | 44 | |
| 44 | 45 | async function mirrorFeed (key, feed) { |
| … | … | @@ -47,6 +48,12 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun |
| 47 | 48 | // this pass be bounded at all. |
| 48 | 49 | let rows |
| 49 | 50 | try { |
| 51 | // ⛔ REFRESH THE VIEW FIRST. `pointers()` reads the linearised view, and the view only | |
| 52 | // advances when apply runs. Replicated blocks can be sitting in the writer cores with the | |
| 53 | // view still behind them — so a sweep that skipped this would keep reporting "nothing new" | |
| 54 | // while the log had in fact grown. This is what makes the pass correct no matter what | |
| 55 | // triggered it. | |
| 56 | if (typeof feed.update === 'function') await feed.update() | |
| 50 | 57 | rows = await feed.pointers() |
| 51 | 58 | } catch (err) { |
| 52 | 59 | // A feed whose view is not ready yet is not a failure of the pass. It will be there next time. |
| … | … | @@ -135,17 +142,56 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun |
| 135 | 142 | * rather than `pass` directly. |
| 136 | 143 | */ |
| 137 | 144 | watch (base) { |
| 138 | if (!base || typeof base.on !== 'function') return () => {} | |
| 139 | const onUpdate = () => this.schedule() | |
| 140 | base.on('update', onUpdate) | |
| 141 | watched.push([base, onUpdate]) | |
| 142 | return () => { try { base.off('update', onUpdate) } catch {} } | |
| 145 | if (!base) return () => {} | |
| 146 | const onAppend = () => this.schedule() | |
| 147 | ||
| 148 | // ⛔ THE WRITER CORES, not the autobee. autobee emits no "view advanced" event: its whole | |
| 149 | // surface is interrupt / error / rotate-local-writer / move-to, and the one `emit('update')` | |
| 150 | // fires inside _onError on an INTERRUPT. 0.0.40 subscribed to it and therefore never fired — | |
| 151 | // caught only by an end-to-end run, because a fake emitter emits whatever you tell it to. | |
| 152 | // | |
| 153 | // A hypercore emits 'append' when blocks land, INCLUDING replicated ones, which is the | |
| 154 | // earliest honest signal that someone else wrote something. | |
| 155 | // | |
| 156 | // ⚠ Writers admitted later are not hooked until the next boot. The periodic sweep below is | |
| 157 | // what stops that being a hole. | |
| 158 | let hooked = 0 | |
| 159 | try { | |
| 160 | for (const w of (base.writers || [])) { | |
| 161 | if (!w || !w.core || typeof w.core.on !== 'function') continue | |
| 162 | w.core.on('append', onAppend) | |
| 163 | watched.push([w.core, onAppend]) | |
| 164 | hooked++ | |
| 165 | } | |
| 166 | } catch (err) { | |
| 167 | log({ msg: 'mirror: could not watch writer cores', error: err.message }) | |
| 168 | } | |
| 169 | if (!hooked) log({ msg: 'mirror: no writer cores to watch — relying on the periodic sweep' }) | |
| 170 | return () => { | |
| 171 | for (const [c, fn] of watched) { try { c.off('append', fn) } catch {} } | |
| 172 | watched.length = 0 | |
| 173 | } | |
| 174 | }, | |
| 175 | ||
| 176 | /** | |
| 177 | * A slow sweep, so a missed signal cannot strand a feed for ever. | |
| 178 | * | |
| 179 | * ⚠ Yes, a timer — and deliberately. The rule this fleet keeps re-learning is not to POLL THE | |
| 180 | * NETWORK for something an event should tell you; this polls a local bee and stats local files, | |
| 181 | * and touches the network only when something is genuinely missing. Given that 0.0.40 shipped a | |
| 182 | * trigger that silently never fired, a floor that cannot be wrong is worth its cost. | |
| 183 | */ | |
| 184 | every (ms = 60_000) { | |
| 185 | if (stopped || interval) return | |
| 186 | interval = setInterval(() => { this.pass().catch(() => {}) }, ms) | |
| 187 | if (interval && interval.unref) interval.unref() | |
| 143 | 188 | }, |
| 144 | 189 | |
| 145 | 190 | stop () { |
| 146 | 191 | stopped = true |
| 147 | 192 | if (timer) { clearTimeout(timer); timer = null } |
| 148 | for (const [base, fn] of watched) { try { base.off('update', fn) } catch {} } | |
| 193 | if (interval) { clearInterval(interval); interval = null } | |
| 194 | for (const [c, fn] of watched) { try { c.off('append', fn) } catch {} } | |
| 149 | 195 | watched.length = 0 |
| 150 | 196 | } |
| 151 | 197 | } |
| … | … | @@ -247,6 +247,11 @@ export class FavelaNode { |
| 247 | 247 | // feed's log has usually not replicated yet, so the real work is done by the 'update' watcher |
| 248 | 248 | // attached in hold(). |
| 249 | 249 | this._mirror.pass().catch(() => {}) |
| 250 | // ⛔ AND A FLOOR UNDER IT. 0.0.40's trigger subscribed to an autobee event that only fires on | |
| 251 | // an interrupt, so it never fired at all — and nothing said so. A slow local sweep cannot be | |
| 252 | // wrong in that way: it reads a bee and stats files, and touches the network only when a record | |
| 253 | // is genuinely missing. | |
| 254 | this._mirror.every() | |
| 250 | 255 | |
| 251 | 256 | return this |
| 252 | 257 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.40", | |
| 3 | "version": "0.0.41", | |
| 4 | 4 | "description": "The favela daemon — feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| … | … | @@ -39,6 +39,17 @@ function fakeFeed (names, { local = [], fail = [], onFetch = () => {} } = {}) { |
| 39 | 39 | } |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | // An autobee's writer cores. `w.core.on('append')` is the real signal that blocks landed — | |
| 43 | // including replicated ones. autobee itself emits no view-advanced event. | |
| 44 | function fakeBase () { | |
| 45 | const h = [] | |
| 46 | const core = { | |
| 47 | on: (_e, fn) => h.push(fn), | |
| 48 | off: (_e, fn) => { const i = h.indexOf(fn); if (i >= 0) h.splice(i, 1) } | |
| 49 | } | |
| 50 | return { writers: [{ core }], emit: () => h.slice().forEach((fn) => fn()), listeners: () => h.length } | |
| 51 | } | |
| 52 | ||
| 42 | 53 | test('★★ it fetches records nobody asked to read', async (t) => { |
| 43 | 54 | const fetched = [] |
| 44 | 55 | const feed = fakeFeed(['a', 'b', 'c'], { onFetch: (n) => fetched.push(n) }) |
| … | … | @@ -154,13 +165,8 @@ test('★★ a log that advances AFTER the boot pass still gets mirrored', async |
| 154 | 165 | } |
| 155 | 166 | } |
| 156 | 167 | |
| 157 | // Minimal emitter — autobee emits 'update' whenever the view advances. | |
| 158 | const handlers = [] | |
| 159 | const base = { | |
| 160 | on: (_e, fn) => handlers.push(fn), | |
| 161 | off: (_e, fn) => { const i = handlers.indexOf(fn); if (i >= 0) handlers.splice(i, 1) }, | |
| 162 | emit: () => handlers.slice().forEach((fn) => fn()) | |
| 163 | } | |
| 168 | // ⛔ A WRITER CORE, not the autobee. autobee emits no view-advanced event — see mirror.js. | |
| 169 | const base = fakeBase() | |
| 164 | 170 | |
| 165 | 171 | const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 10, log: () => {} }) |
| 166 | 172 | m.watch(base) |
| … | … | @@ -181,8 +187,7 @@ test('★ a burst of updates coalesces into one pass', async (t) => { |
| 181 | 187 | async pointers () { passes++; return [] }, |
| 182 | 188 | store: { async get () { return null }, async ensureLocal () { return null } } |
| 183 | 189 | } |
| 184 | const handlers = [] | |
| 185 | const base = { on: (_e, fn) => handlers.push(fn), off: () => {}, emit: () => handlers.forEach((fn) => fn()) } | |
| 190 | const base = fakeBase() | |
| 186 | 191 | |
| 187 | 192 | const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 20, log: () => {} }) |
| 188 | 193 | m.watch(base) |
| … | … | @@ -198,12 +203,7 @@ test('★ stop() detaches the watcher', async (t) => { |
| 198 | 203 | async pointers () { passes++; return [] }, |
| 199 | 204 | store: { async get () { return null }, async ensureLocal () { return null } } |
| 200 | 205 | } |
| 201 | const handlers = [] | |
| 202 | const base = { | |
| 203 | on: (_e, fn) => handlers.push(fn), | |
| 204 | off: (_e, fn) => { const i = handlers.indexOf(fn); if (i >= 0) handlers.splice(i, 1) }, | |
| 205 | emit: () => handlers.slice().forEach((fn) => fn()) | |
| 206 | } | |
| 206 | const base = fakeBase() | |
| 207 | 207 | |
| 208 | 208 | const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 10, log: () => {} }) |
| 209 | 209 | m.watch(base) |
| … | … | @@ -212,5 +212,47 @@ test('★ stop() detaches the watcher', async (t) => { |
| 212 | 212 | await new Promise((r) => setTimeout(r, 40)) |
| 213 | 213 | |
| 214 | 214 | t.is(passes, 0, 'nothing sweeps after stop') |
| 215 | t.is(handlers.length, 0, 'and the listener is gone') | |
| 215 | t.is(base.listeners(), 0, 'and the listener is gone') | |
| 216 | }) | |
| 217 | ||
| 218 | // ⛔ The view only advances when apply runs, so replicated blocks can be sitting in the writer | |
| 219 | // cores with `pointers()` still reporting the old set. A sweep that skipped the refresh would keep | |
| 220 | // finding "nothing new" while the log had grown — which is indistinguishable from the 0.0.40 bug | |
| 221 | // and would have been just as invisible. | |
| 222 | test('★★ a sweep refreshes the view before reading pointers', async (t) => { | |
| 223 | const order = [] | |
| 224 | let names = [] | |
| 225 | const feed = { | |
| 226 | async update () { order.push('update'); names = ['fresh'] }, | |
| 227 | async pointers () { | |
| 228 | order.push('pointers') | |
| 229 | return names.map((n) => ({ key: 'post/' + n, collection: 'post', name: n, pointer: { $oid: 'o' } })) | |
| 230 | }, | |
| 231 | store: { async get () { return null }, async ensureLocal (_c, n) { order.push('fetch:' + n); return { body: n } } } | |
| 232 | } | |
| 233 | ||
| 234 | await createMirror({ feeds: () => [{ key: 'f1', feed }], log: () => {} }).pass() | |
| 235 | ||
| 236 | t.alike(order, ['update', 'pointers', 'fetch:fresh'], 'refresh, then read, then fetch') | |
| 237 | }) | |
| 238 | ||
| 239 | // ⚠ A timer on purpose. 0.0.40 shipped a trigger that silently never fired; a floor that cannot be | |
| 240 | // wrong is worth its cost. It polls a local bee and stats local files — not the network. | |
| 241 | test('★ the periodic sweep is a floor under a missed signal', async (t) => { | |
| 242 | let passes = 0 | |
| 243 | const feed = { | |
| 244 | async update () {}, | |
| 245 | async pointers () { passes++; return [] }, | |
| 246 | store: { async get () { return null }, async ensureLocal () { return null } } | |
| 247 | } | |
| 248 | const m = createMirror({ feeds: () => [{ key: 'f1', feed }], log: () => {} }) | |
| 249 | ||
| 250 | m.every(20) | |
| 251 | await new Promise((r) => setTimeout(r, 90)) | |
| 252 | m.stop() | |
| 253 | const seen = passes | |
| 254 | await new Promise((r) => setTimeout(r, 50)) | |
| 255 | ||
| 256 | t.ok(seen >= 2, 'it kept sweeping with no event at all (' + seen + ')') | |
| 257 | t.is(passes, seen, 'and stop() ended it') | |
| 216 | 258 | }) |