| … | … | @@ -1,9 +1,9 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.46", | |
| 3 | "version": "0.0.47", | |
| 4 | 4 | "runtime": "bare", |
| 5 | "rootTreeHash": "83d403e7ecbb4ad299b588e76a51781a94f18d1a807750e934cc058612288b4a", | |
| 6 | "publishedAt": "2026-09-08T22:09:42.828Z", | |
| 5 | "rootTreeHash": "0606f31132dea63b0e3301866b53c579c7432abad2f064a9853bab27093b6f27", | |
| 6 | "publishedAt": "2026-09-09T00:45:20.793Z", | |
| 7 | 7 | "deps": [ |
| 8 | 8 | { |
| 9 | 9 | "name": "@hyperswarm/secret-stream", |
| … | … | @@ -3016,8 +3016,8 @@ |
| 3016 | 3016 | "tree": "c5f18b3620514f54714aeb351f074adface216a335242e323e33d28d261376fe" |
| 3017 | 3017 | } |
| 3018 | 3018 | ], |
| 3019 | "reason": "version-splits only: 0 code-differs", | |
| 3020 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3019 | "reason": "version-splits only", | |
| 3020 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3021 | 3021 | }, |
| 3022 | 3022 | { |
| 3023 | 3023 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3061,8 +3061,8 @@ |
| 3061 | 3061 | "tree": "6bb5233025d1c30e4f181e29b6ab5af549d8909c7070c7b4438b30f1a4e76ce2" |
| 3062 | 3062 | } |
| 3063 | 3063 | ], |
| 3064 | "reason": "version-splits only: 0 code-differs", | |
| 3065 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3064 | "reason": "version-splits only", | |
| 3065 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3066 | 3066 | }, |
| 3067 | 3067 | { |
| 3068 | 3068 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3076,8 +3076,8 @@ |
| 3076 | 3076 | "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d" |
| 3077 | 3077 | } |
| 3078 | 3078 | ], |
| 3079 | "reason": "version-splits only: 0 code-differs", | |
| 3080 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3079 | "reason": "version-splits only", | |
| 3080 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3081 | 3081 | }, |
| 3082 | 3082 | { |
| 3083 | 3083 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3101,8 +3101,8 @@ |
| 3101 | 3101 | "tree": "bdebba5a532ce4846b49f1e5ffad774becfc44182d0c3737f7e441ab42f4c43d" |
| 3102 | 3102 | } |
| 3103 | 3103 | ], |
| 3104 | "reason": "version-splits only: 0 code-differs", | |
| 3105 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3104 | "reason": "version-splits only", | |
| 3105 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3106 | 3106 | }, |
| 3107 | 3107 | { |
| 3108 | 3108 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3131,8 +3131,8 @@ |
| 3131 | 3131 | "tree": "b799e6ba8726aa951b45f12cc7ecffe5f4e9ee1e8adf09a34d026d9d5658828a" |
| 3132 | 3132 | } |
| 3133 | 3133 | ], |
| 3134 | "reason": "version-splits only: 0 code-differs", | |
| 3135 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3134 | "reason": "version-splits only", | |
| 3135 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3136 | 3136 | }, |
| 3137 | 3137 | { |
| 3138 | 3138 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3146,8 +3146,8 @@ |
| 3146 | 3146 | "tree": "e92100a4f821a638ac99c1407e4c7e784f8eb9661e6d9119ecbec4faae77cc7e" |
| 3147 | 3147 | } |
| 3148 | 3148 | ], |
| 3149 | "reason": "version-splits only: 0 code-differs", | |
| 3150 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3149 | "reason": "version-splits only", | |
| 3150 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3151 | 3151 | }, |
| 3152 | 3152 | { |
| 3153 | 3153 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3161,8 +3161,8 @@ |
| 3161 | 3161 | "tree": "1f5de2daa08643a5cb8bce2153ca4d55d4504fb366f1f1ef80bca1700ec085f7" |
| 3162 | 3162 | } |
| 3163 | 3163 | ], |
| 3164 | "reason": "version-splits only: 0 code-differs", | |
| 3165 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3164 | "reason": "version-splits only", | |
| 3165 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3166 | 3166 | }, |
| 3167 | 3167 | { |
| 3168 | 3168 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3176,8 +3176,8 @@ |
| 3176 | 3176 | "tree": "579bdc1eaabbca726d3916f1fc74cfd4986653f6748632db586817a2a8caabea" |
| 3177 | 3177 | } |
| 3178 | 3178 | ], |
| 3179 | "reason": "version-splits only: 0 code-differs", | |
| 3180 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3179 | "reason": "version-splits only", | |
| 3180 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3181 | 3181 | }, |
| 3182 | 3182 | { |
| 3183 | 3183 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3191,8 +3191,8 @@ |
| 3191 | 3191 | "tree": "967cf214778dbacd7cc7cc3850ffdff6a6f145b4bd73de741d0938d7d84d33d5" |
| 3192 | 3192 | } |
| 3193 | 3193 | ], |
| 3194 | "reason": "version-splits only: 0 code-differs", | |
| 3195 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3194 | "reason": "version-splits only", | |
| 3195 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3196 | 3196 | }, |
| 3197 | 3197 | { |
| 3198 | 3198 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3216,8 +3216,8 @@ |
| 3216 | 3216 | "tree": "35b214bfbf85f8bbcb9f1926470e77f4e240d9b5045b07f81ce83fec24c1380c" |
| 3217 | 3217 | } |
| 3218 | 3218 | ], |
| 3219 | "reason": "version-splits only: 0 code-differs", | |
| 3220 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3219 | "reason": "version-splits only", | |
| 3220 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3221 | 3221 | }, |
| 3222 | 3222 | { |
| 3223 | 3223 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3231,8 +3231,8 @@ |
| 3231 | 3231 | "tree": "67c4c05f69061f65a2548595c43282be2da8e0934e6ce347afa034b29cc40c81" |
| 3232 | 3232 | } |
| 3233 | 3233 | ], |
| 3234 | "reason": "version-splits only: 0 code-differs", | |
| 3235 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3234 | "reason": "version-splits only", | |
| 3235 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3236 | 3236 | }, |
| 3237 | 3237 | { |
| 3238 | 3238 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3256,8 +3256,8 @@ |
| 3256 | 3256 | "tree": "4ef8a03ba96b1488cb7ee35dc582b14d096ae4b3fab5481186c94516d434fe18" |
| 3257 | 3257 | } |
| 3258 | 3258 | ], |
| 3259 | "reason": "version-splits only: 0 code-differs", | |
| 3260 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3259 | "reason": "version-splits only", | |
| 3260 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3261 | 3261 | }, |
| 3262 | 3262 | { |
| 3263 | 3263 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3281,8 +3281,8 @@ |
| 3281 | 3281 | "tree": "5fc49d698f6ddfd653b3cdaa72ec572d6804293e2e9c3e98303f69417115e903" |
| 3282 | 3282 | } |
| 3283 | 3283 | ], |
| 3284 | "reason": "version-splits only: 0 code-differs", | |
| 3285 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3284 | "reason": "version-splits only", | |
| 3285 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3286 | 3286 | }, |
| 3287 | 3287 | { |
| 3288 | 3288 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3296,8 +3296,8 @@ |
| 3296 | 3296 | "tree": "d0099e1743480f28a08c7374d20ad5048088475180ab606626b63acd59e365a5" |
| 3297 | 3297 | } |
| 3298 | 3298 | ], |
| 3299 | "reason": "version-splits only: 0 code-differs", | |
| 3300 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3299 | "reason": "version-splits only", | |
| 3300 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3301 | 3301 | }, |
| 3302 | 3302 | { |
| 3303 | 3303 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3311,8 +3311,8 @@ |
| 3311 | 3311 | "tree": "60429c7756f039ec2b3e5240f702dee450c1da19ad6de06900a3877c6c72bb52" |
| 3312 | 3312 | } |
| 3313 | 3313 | ], |
| 3314 | "reason": "version-splits only: 0 code-differs", | |
| 3315 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3314 | "reason": "version-splits only", | |
| 3315 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3316 | 3316 | }, |
| 3317 | 3317 | { |
| 3318 | 3318 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3336,8 +3336,8 @@ |
| 3336 | 3336 | "tree": "6fc80ad1ccbfb046613f36299032f21a4752e2614ff8d88a7a5bd7ce66fc1293" |
| 3337 | 3337 | } |
| 3338 | 3338 | ], |
| 3339 | "reason": "version-splits only: 0 code-differs", | |
| 3340 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3339 | "reason": "version-splits only", | |
| 3340 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3341 | 3341 | }, |
| 3342 | 3342 | { |
| 3343 | 3343 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3356,8 +3356,8 @@ |
| 3356 | 3356 | "tree": "92424acff1a50468df203528e41739f44f602904abc4beb34500ce172c6d0ad2" |
| 3357 | 3357 | } |
| 3358 | 3358 | ], |
| 3359 | "reason": "version-splits only: 0 code-differs", | |
| 3360 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3359 | "reason": "version-splits only", | |
| 3360 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3361 | 3361 | }, |
| 3362 | 3362 | { |
| 3363 | 3363 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3376,8 +3376,8 @@ |
| 3376 | 3376 | "tree": "36d01631ddee74ac2ce62971abd7c0ac30562da8dfd6495480da56c534eb80b7" |
| 3377 | 3377 | } |
| 3378 | 3378 | ], |
| 3379 | "reason": "version-splits only: 0 code-differs", | |
| 3380 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3379 | "reason": "version-splits only", | |
| 3380 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3381 | 3381 | }, |
| 3382 | 3382 | { |
| 3383 | 3383 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3391,8 +3391,8 @@ |
| 3391 | 3391 | "tree": "bba869f5c85b11bce5671d57a87cd2793b490588867cd07fbe94cad796114970" |
| 3392 | 3392 | } |
| 3393 | 3393 | ], |
| 3394 | "reason": "version-splits only: 0 code-differs", | |
| 3395 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3394 | "reason": "version-splits only", | |
| 3395 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3396 | 3396 | }, |
| 3397 | 3397 | { |
| 3398 | 3398 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3406,8 +3406,8 @@ |
| 3406 | 3406 | "tree": "2ecc91a8c3595dda5dfc86ace62a70875520adc4e588a2ac14945dbc4313c450" |
| 3407 | 3407 | } |
| 3408 | 3408 | ], |
| 3409 | "reason": "version-splits only: 0 code-differs", | |
| 3410 | "acceptedAt": "2026-09-08T22:09:42.842Z" | |
| 3409 | "reason": "version-splits only", | |
| 3410 | "acceptedAt": "2026-09-09T00:45:20.805Z" | |
| 3411 | 3411 | } |
| 3412 | 3412 | ] |
| 3413 | 3413 | } |
| … | … | @@ -112,9 +112,21 @@ export class RecordStore { |
| 112 | 112 | // |
| 113 | 113 | // ⛔ A local hit is VERIFIED against `oid` before it is returned. Skipping that check is what let |
| 114 | 114 | // two co-owners hold identical pointer sets, resolve them both happily, and publish different |
| 115 | // content. On a positive mismatch this returns null rather than the superseded copy: the record | |
| 116 | // reads as unresolved and the caller stalls, which is the correct failure. Advancing with content | |
| 117 | // that does not match what the group agreed on is not. | |
| 115 | // content. A superseded copy is never returned: stalling loudly beats advancing with content | |
| 116 | // nobody agreed on. | |
| 117 | // | |
| 118 | // ⛔⛔ BUT A MISMATCH MUST FETCH, and for a long time it did not — it returned null right there, | |
| 119 | // and the fetch below was reachable only when there was no local file at all. Posts never hit it, | |
| 120 | // because a post is written under a fresh name and has nothing to be stale. The PROFILE is the | |
| 121 | // one record in the model that is MUTABLE and overwritten in place at the same (collection, | |
| 122 | // name) — so the moment its author edited it, every holder's copy went stale, the pointer named | |
| 123 | // bytes they would never go and get, and the record read as unresolved FOR EVER. Not "until the | |
| 124 | // next pass": nothing in the system would ever fetch it. That is why a profile change had never | |
| 125 | // once reached a follower, and why the symptom was a profile that went BLANK rather than one that | |
| 126 | // failed to update. | |
| 127 | // | |
| 128 | // "Do not return the stale copy" and "do not go and get the right one" are different rules, and | |
| 129 | // only the first was ever intended. | |
| 118 | 130 | async ensureLocal (collection, name, oid) { |
| 119 | 131 | const local = await this.get(collection, name) |
| 120 | 132 | if (local != null) { |
| … | … | @@ -123,7 +135,8 @@ export class RecordStore { |
| 123 | 135 | // Only a POSITIVE mismatch is staleness. If the bytes cannot be hashed we cannot judge them, |
| 124 | 136 | // and inventing a verdict would re-resolve every record on every pass. |
| 125 | 137 | if (!h || h.oid === oid) return local |
| 126 | return null | |
| 138 | // Stale — fall through to the fetch. The superseded bytes stay addressable by their own oid | |
| 139 | // in the overlay; what is replaced here is only this name's view of which version is current. | |
| 127 | 140 | } |
| 128 | 141 | if (!this._overlay || !oid) return null |
| 129 | 142 | const disk = this._pathFor(collection, name) |
| … | … | @@ -137,6 +150,17 @@ export class RecordStore { |
| 137 | 150 | ]) |
| 138 | 151 | if (!r) return null |
| 139 | 152 | } catch { return null } |
| 153 | ||
| 154 | // ⛔ VERIFY WHAT LANDED, because "a fetch returned" is not "the agreed bytes are on disk". Two | |
| 155 | // ways it can be false: a peer answers with content that does not hash to what was asked for, | |
| 156 | // and — the one that actually shipped, in files.js and avatars.js (0.0.46) — the fetch lands at | |
| 157 | // its own `destPath` while the caller re-reads its own path and quietly returns the OLD bytes. | |
| 158 | // Without this check, a stale record would refuse to resolve and then, one line later, resolve | |
| 159 | // to exactly the stale content it just refused. | |
| 160 | if (typeof this._overlay.hashFile === 'function') { | |
| 161 | const after = await this.localOid(collection, name) | |
| 162 | if (after && after.oid !== oid) return null | |
| 163 | } | |
| 140 | 164 | return this.get(collection, name) |
| 141 | 165 | } |
| 142 | 166 |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.47", | |
| 3 | "version": "0.0.48", | |
| 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": { |
| … | … | @@ -106,3 +106,76 @@ test('⛔ ensureLocal VERIFIES bytes against the expected oid (invariants 2 and |
| 106 | 106 | t.is(await store.ensureLocal('post', 'a1', oid), null, |
| 107 | 107 | 'a mismatch returns NULL and stalls loudly — never the stale copy') |
| 108 | 108 | }) |
| 109 | ||
| 110 | // ⛔⛔ THE ONE THAT SHIPPED, and it is the reason a profile change never reached anybody. | |
| 111 | // | |
| 112 | // Invariant 3 says a mismatch must not return the stale copy. It does not say STOP — but that is | |
| 113 | // what the code did: on a positive mismatch it returned null and never reached the fetch below, | |
| 114 | // which was only reachable when there was no local file at all. | |
| 115 | // | |
| 116 | // Posts never hit it, because a post is written under a fresh name and there is no local file to be | |
| 117 | // stale. The PROFILE is the one record in the model that is MUTABLE and overwritten in place at the | |
| 118 | // same (collection, name) — so the moment its author edited it, every holder's copy went stale, the | |
| 119 | // pointer named bytes they would never go and get, and the record read as unresolved FOR EVER. | |
| 120 | // | |
| 121 | // Measured live 2026-09-08: ashatron set a bio and a new avatar; the pointer replicated to the | |
| 122 | // bridge in seconds and the actor's `summary` and `icon` both went NULL — it lost the profile it | |
| 123 | // already had. The new avatar's BYTES were fetchable through the same overlay at the same moment | |
| 124 | // (200, 1377 bytes), which is what ruled out a transport problem and pointed here. | |
| 125 | // | |
| 126 | // ⚠ Verified AFTER the fetch too. `files.js` and `avatars.js` both shipped a bug where the fetch | |
| 127 | // landed at `r.destPath` and the caller re-read its own path, quietly returning the old bytes | |
| 128 | // (0.0.46). Re-hashing is what makes "fetched" mean "fetched the agreed content" rather than | |
| 129 | // "something is on disk now". | |
| 130 | test('⛔⛔ a STALE local copy is REFETCHED, not just refused', async (t) => { | |
| 131 | const root = tmp() | |
| 132 | // The peer that has the bytes: content hash = the bytes, so any content is addressable by them. | |
| 133 | const remote = new Map() | |
| 134 | const overlay = { | |
| 135 | async registerFile (_p, disk) { const b = fs.readFileSync(disk); const h = b4a.toString(b, 'hex'); remote.set(h, b); return { contentHash: h, size: b.length } }, | |
| 136 | async hashFile (p) { try { const b = fs.readFileSync(p); return { contentHash: b4a.toString(b, 'hex'), size: b.length } } catch { return null } }, | |
| 137 | async fetchFile (oid, { destPath } = {}) { | |
| 138 | const b = remote.get(oid) | |
| 139 | if (!b) return null | |
| 140 | fs.writeFileSync(destPath, b) | |
| 141 | return { contentHash: oid, size: b.length, destPath } | |
| 142 | }, | |
| 143 | async unregisterFile () {} | |
| 144 | } | |
| 145 | const store = new RecordStore(root, { overlay }) | |
| 146 | ||
| 147 | // What the group agreed on, registered with the overlay so a peer can serve it. | |
| 148 | const agreed = serialize(rec('the profile everyone agreed on')) | |
| 149 | const tmpPath = join(root, 'agreed.md') | |
| 150 | fs.writeFileSync(tmpPath, agreed) | |
| 151 | const { contentHash: agreedOid } = await overlay.registerFile('x', tmpPath) | |
| 152 | ||
| 153 | // This node holds an OLDER copy under the same name — exactly a profile before its author edited it. | |
| 154 | await store.put('profile', 'self', rec('the profile as it used to be')) | |
| 155 | t.not((await store.localOid('profile', 'self')).oid, agreedOid, 'genuinely stale') | |
| 156 | ||
| 157 | const got = await store.ensureLocal('profile', 'self', agreedOid) | |
| 158 | t.ok(got, 'resolves — a mutable record must not be permanently unreadable because it changed') | |
| 159 | t.is(got.body, 'the profile everyone agreed on\n', 'and it is the content the POINTER names') | |
| 160 | t.is((await store.localOid('profile', 'self')).oid, agreedOid, 'the disk copy converged too') | |
| 161 | }) | |
| 162 | ||
| 163 | test('⛔ but content that does not hash to the pointer is still refused after a fetch', async (t) => { | |
| 164 | const root = tmp() | |
| 165 | const overlay = { | |
| 166 | async registerFile (_p, disk) { const b = fs.readFileSync(disk); return { contentHash: b4a.toString(b, 'hex'), size: b.length } }, | |
| 167 | async hashFile (p) { try { const b = fs.readFileSync(p); return { contentHash: b4a.toString(b, 'hex'), size: b.length } } catch { return null } }, | |
| 168 | // A peer that answers with the WRONG bytes. Claiming success is the dangerous case: the caller | |
| 169 | // would otherwise re-read its own path and publish whatever landed there. | |
| 170 | async fetchFile (oid, { destPath } = {}) { | |
| 171 | fs.writeFileSync(destPath, serialize(rec('not what was asked for'))) | |
| 172 | return { contentHash: oid, size: 1, destPath } | |
| 173 | }, | |
| 174 | async unregisterFile () {} | |
| 175 | } | |
| 176 | const store = new RecordStore(root, { overlay }) | |
| 177 | await store.put('profile', 'self', rec('the profile as it used to be')) | |
| 178 | ||
| 179 | t.is(await store.ensureLocal('profile', 'self', 'ffff'.repeat(16)), null, | |
| 180 | 'null — stalling beats advancing with content nobody agreed on') | |
| 181 | }) |