| … | … | @@ -553,9 +553,23 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe |
| 553 | 553 | // autobase/bytes-in-the-overlay design PointerSubstrate already uses for records. Reserved "__" |
| 554 | 554 | // key: splitKey rejects it, so it can never reach a signed commit. |
| 555 | 555 | blobs = new DualBlobStore(new BlobPointerStore(sb, overlayBlobs, { did }), legacyBlobs) |
| 556 | blobStore = blobs // expose to the reconcile runner (see the hoisted declaration above) | |
| 557 | 556 | console.log(JSON.stringify({ msg: 'blob store: overlay content-addressed files + shared pointers + legacy dual-read', did, path: join(repoRoot, 'blobs') })) |
| 558 | 557 | } |
| 558 | // ⛔ ALWAYS, NOT ONLY ON THE OVERLAY PATH. This assignment lived inside the `if` above, so a DID | |
| 559 | // with no `storeBackend: 'overlay'` — which is every DID minted by favela-host, since it sets none | |
| 560 | // — returned `blobs: null` from bootMultiDevice while `legacyBlobs` was right here, working, and | |
| 561 | // already handed to serveDevice and serveMembrane. The two stores share a drop-in put/get/has API | |
| 562 | // by design, so there was never a reason for the handle to report one and not the other. | |
| 563 | // | |
| 564 | // ⚠ IT ONLY BITES AN IN-PROCESS CONSUMER, which is why it survived: every socket surface takes | |
| 565 | // `blobs` directly from this scope. The returned field exists precisely FOR the in-process case | |
| 566 | // (see the note where this node is returned), and that is the one case it got wrong. | |
| 567 | // | |
| 568 | // Measured: a handset published 11 posts to its atproto repo and NONE of them carried an image. | |
| 569 | // The same posts reached the fediverse with their jpegs attached, because that projection never | |
| 570 | // asks this object for a blob store. upload-blob threw 'this node has no blob store', BskySink | |
| 571 | // caught it and returned null, and each post went as text with a loss line nobody reads. | |
| 572 | blobStore = blobs | |
| 559 | 573 | // LOCAL UDS control surface — bring it up EARLY (before serveDevice/serveMembrane, whose sync/ready can |
| 560 | 574 | // stall on an un-converged base's base.update), so a co-located client (favela-web) can ALWAYS reach the |
| 561 | 575 | // device over local IPC even while convergence lags. Owner-gated by the filesystem → no writer-allowlist. |
| … | … | @@ -9,6 +9,7 @@ import './conflict-watch.test.js' // fast + offline; drives a REAL conflict — |
| 9 | 9 | import './side-store-channel-priority.test.js' // fast + offline |
| 10 | 10 | import './shared-store-autoopen.test.js' // fast + offline; corestore auto-opens on-disk cores on remote interest (#22) |
| 11 | 11 | import './inline-migration-boot.test.mjs' // fast + offline |
| 12 | import './blob-store-exposed.test.mjs' // fast + offline; the returned handle must report the store it has | |
| 12 | 13 | import './mint.test.mjs' // fast + offline; creating an identity on the device that will use it |
| 13 | 14 | import './hosting-cli.test.mjs' // fast + offline; the invite ceremony's terminal half |
| 14 | 15 | import './swarm-device.test.js' |
| … | … | @@ -0,0 +1,48 @@ |
| 1 | // The blob store a booted node REPORTS must be the one it actually has. | |
| 2 | // | |
| 3 | // ⛔ THE DEFECT. `blobStore` is hoisted (the reconcile runner needs it) and was assigned ONLY inside | |
| 4 | // `if (storeBackend === 'overlay' && repoRoot && recordOverlay)`. Every other consumer takes `blobs` | |
| 5 | // straight from that scope, so serveDevice, serveMembrane and the UDS surface all had a working store | |
| 6 | // — while the object bootSwarmDevice RETURNS carried `blobs: null` for any DID without the overlay | |
| 7 | // backend. favela-host mints DIDs and sets no storeBackend, so that is every DID it has ever made. | |
| 8 | // | |
| 9 | // ⚠ AND IT ONLY BITES AN IN-PROCESS CONSUMER, which is exactly why it survived: the returned field | |
| 10 | // exists FOR the in-process case (the note beside the return says so, naming favela-android), and that | |
| 11 | // is the single case it got wrong. Every socket path reads the other variable. | |
| 12 | // | |
| 13 | // Measured on a handset: 11 posts published to its atproto repo, NONE carrying an image, while the | |
| 14 | // same posts reached the fediverse with their jpegs attached — that projection never asks this object | |
| 15 | // for a blob store. `upload-blob` threw 'this node has no blob store', BskySink caught it and returned | |
| 16 | // null, and each post went out as text with a loss line nobody reads. | |
| 17 | // | |
| 18 | // ⚠ SOURCE-LEVEL, and deliberately. Observing it otherwise means booting a swarm device with a real | |
| 19 | // corestore and overlay — minutes, and network-shaped — to assert one field of the returned object. | |
| 20 | // The defect is an ASSIGNMENT IN THE WRONG SCOPE, which is exactly what reading the source can pin. | |
| 21 | ||
| 22 | import test from 'brittle' | |
| 23 | import fs from 'bare-fs' | |
| 24 | ||
| 25 | const SRC = new URL('../lib/device-node.js', import.meta.url).pathname | |
| 26 | ||
| 27 | test('the returned blob store is assigned outside the overlay branch', (t) => { | |
| 28 | const src = fs.readFileSync(SRC, 'utf8') | |
| 29 | ||
| 30 | t.absent(/blobStore = blobs \/\/ expose to the reconcile runner/.test(src), | |
| 31 | 'the assignment no longer sits inside the overlay-only branch') | |
| 32 | ||
| 33 | // It must be reached whether or not the branch above ran. The branch closes with `}` on its own | |
| 34 | // line; the assignment has to come after it, at the outer indentation. | |
| 35 | t.ok(/\n blobStore = blobs\n/.test(src), | |
| 36 | 'blobStore is assigned at the same level as the `if`, so a legacy DID reports its store too') | |
| 37 | ||
| 38 | // And the two stores are interchangeable by design — the comment in device-node.js says "Same | |
| 39 | // drop-in put/get/has API either way" — so reporting the legacy one is correct, not a downgrade. | |
| 40 | t.ok(/let blobs = legacyBlobs/.test(src), 'legacy is the default store, and it is a real one') | |
| 41 | }) | |
| 42 | ||
| 43 | test('nothing else re-narrows it afterwards', (t) => { | |
| 44 | const src = fs.readFileSync(SRC, 'utf8') | |
| 45 | const assignments = (src.match(/^\s*blobStore = /gm) || []) | |
| 46 | t.is(assignments.length, 1, 'exactly one assignment, so there is no second path that can null it') | |
| 47 | t.ok(/blobs: blobStore/.test(src), 'and it is still what the node hands back') | |
| 48 | }) |