From 75313f75a2679f89623999b578edc0a52d5b3337 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 21 Sep 2026 15:00:01 +0200 Subject: [PATCH] fix!: une lecture qui n'atteint pas son document echoue, elle ne repond plus vide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fetchReadCap` rendait `false` pour trois situations sans rapport : l'emulation inerte, le document qui n'expose aucune capacite, et le reseau qu'on n'a pas pu interroger. Les trois appelants vivants lisaient ce `false` comme « pas de capacite » et transformaient une panne en affirmation sur le document : `ensureRepoOpen` renoncait en silence et laissait la lecture suivante accuser le lecteur de ne rien detenir, et `documentInboxAddress` repondait « ce document n'a pas d'inbox », ce qu'une application lit comme zero depot. Le telechargement rejette desormais jusqu'a l'appelant, avec le document nomme et la cause d'origine conservee. `false` ne veut plus dire qu'une chose : le document n'expose rien, et c'est une reponse. Le memo garde la meme asymetrie qu'avant. `documentInboxAddress` propage aussi l'echec de sa propre lecture d'adresse : le `catch` qui rendait `undefined` se justifiait par un refus que `mustNotAttempt` a deja ecarte une ligne plus haut, donc il n'avalait que des pannes. `useShapeQuery` franchit la barriere sur chaque document NOMME par `scope.graphs` avant d'ouvrir la requete : capacite demandee au store public, refus a voix haute si elle n'est pas detenue, ouverture et synchronisation. Un document nomme qu'on ne peut pas lire donnait `isSuccess` avec `data: []` apres que le filtre de capacites l'eut vide - la seule reponse que cette surface existe pour reserver a « ce scope ne contient rien ». Restent intacts et succes : `did:ng:i`, `graphs: []`, un document joignable et vide, et tout le cas inerte. `fetchReadCaps` supprime : aucun appelant, et une politique de tolerance par lot sans proprietaire au-dessus d'un appel qui rejette desormais. --- .../src/emulated-verifier/branch-registers.ts | 23 ++- .../src/emulated-verifier/open-repo.ts | 17 +- .../src/emulated-verifier/public-store.ts | 65 +++++--- packages/polyfill/src/surface/docs.ts | 5 + .../polyfill/src/surface/use-shape-query.ts | 148 +++++++++++++++--- .../document-inbox-surfaces-failure.test.ts | 101 ++++++++++++ packages/polyfill/test/public-store.test.ts | 53 ++++++- .../polyfill/test/use-shape-query.test.ts | 111 +++++++++++++ 8 files changed, 463 insertions(+), 60 deletions(-) create mode 100644 packages/polyfill/test/document-inbox-surfaces-failure.test.ts diff --git a/packages/polyfill/src/emulated-verifier/branch-registers.ts b/packages/polyfill/src/emulated-verifier/branch-registers.ts index bfa5b50..3bfbf63 100644 --- a/packages/polyfill/src/emulated-verifier/branch-registers.ts +++ b/packages/polyfill/src/emulated-verifier/branch-registers.ts @@ -302,6 +302,14 @@ export async function restoreOwnCaps(holderKey: string): Promise { * * **Never creates.** Asking where to deposit must not bring an inbox into existence — * only its owner opens one, and only on its own document. + * + * **`undefined` is an ANSWER, and only an answer** (`1.0.0-dev.9`). It means the document + * carries no address — nobody opened an inbox on it — or that it is not this holder's to + * see one on. It no longer means "the read did not happen": both routes by which a + * breakdown used to arrive here as `undefined` now reject. That mattered because two + * published calls turn this value into a statement an application acts on: + * `inbox.postToDocument` says "its owner never opened one", and `inbox.readForDocument` + * says zero deposits — for a document whose inbox is full. */ // @provenance documentInboxAddress kind=divergent level=1 ref=engine/net/src/types.rs:ContactDetails — the deposit-side half of the same divergence: upstream you are TOLD an address, you never read one off a document export async function documentInboxAddress(doc: Nuri): Promise { @@ -313,7 +321,9 @@ export async function documentInboxAddress(doc: Nuri): Promise // …but ask the (emulated) network first: a document in a public store serves its cap // to whoever asks (public-store.ts), and "where do I deposit for this public // document" is exactly the question a third party arrives with, holding nothing but - // the reference. + // the reference. Its rejection is NOT caught: "the store could not be asked" is not + // "this document has no inbox", and the whole value of the answer below is that the + // two are told apart. await fetchReadCap(doc); if (mustNotAttempt(doc)) return undefined; const s = await session(); @@ -330,10 +340,17 @@ export async function documentInboxAddress(doc: Nuri): Promise if (a && isNuri(a)) return a; } } catch (error) { - // Unreadable document (no cap) or not synced → no address to give. Refusing to - // read is the boundary doing its job, not an error to propagate here. + // **Propagates a failed read** (`1.0.0-dev.9`). This used to answer `undefined`, on + // the ground that "refusing to read is the boundary doing its job" — but the boundary + // has already had its say at `mustNotAttempt` above, which returned before reaching + // here. What this catch could still meet is a breakdown: a repo that would not open, + // a broker that did not answer, a query that rejected. Answering `undefined` for that + // told the caller the document has no inbox, which is the failure-as-absence this + // path has paid for before. A caller that genuinely prefers to carry on catches it. console.error(accessLogPrefix() + " documentInboxAddress failed:", error); + throw error; } + // No address on the Header branch: nobody opened an inbox on this document. An answer. return undefined; } diff --git a/packages/polyfill/src/emulated-verifier/open-repo.ts b/packages/polyfill/src/emulated-verifier/open-repo.ts index 2077853..59cb056 100644 --- a/packages/polyfill/src/emulated-verifier/open-repo.ts +++ b/packages/polyfill/src/emulated-verifier/open-repo.ts @@ -188,8 +188,16 @@ async function syncSession(): Promise { * `"synced"`). Idempotent: a repo already opened (or in flight) is not re-opened. * * Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g. - * the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged. - * Never throws; a failed open just leaves the read to behave as it did before. + * the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged. The + * open itself never throws: a `doc_subscribe` that never pushes a `State` is bounded by + * the fallback timeout and leaves the read to behave as it did before. + * + * **It DOES throw when the public store could not be asked** (`1.0.0-dev.9`). That is the + * one step here that can fail to ANSWER, and until then it answered `false` — "this + * document exposes no cap" — so the open declined silently and the anchored read that + * followed refused the reader for holding no cap, which was true but was not the reason. + * A caller that means to carry on regardless says so with a `catch` of its own; none does + * today, because every one of them is about to read the document. * * `holderKey` names WHOSE possession decides, when it is not the connected identity's — * the emulated inbox processor opening an inbox repo for its owner during someone else's @@ -205,7 +213,10 @@ export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise { * Nothing is asked when the cap is already held: a document you can read needs no * fetching, and skipping it keeps the ordinary path free of physical reads. * - * Never throws. Two different negatives reach the caller as the same `false`, and the - * caller is right not to tell them apart — it obtained no cap either way. They are kept - * apart where it matters, in the memo: an absence is remembered, a failure is not (see - * the note on {@link attempted}). + * **Two outcomes only, and a THIRD that is not an outcome.** `false` means the network + * answered and the document exposes no cap — an absence, and the normal case. `true` means + * a cap was obtained and filed. A download that REJECTED is neither: it REJECTS here too. + * + * It used to answer `false` for that as well, which is the defect this function was the + * head of. `false` then meant "no cap" to three call sites at once, and each of them + * turned a breakdown into a statement about the document: {@link ensureRepoOpen} declined + * to open and let the read that followed blame the reader for holding no cap, and + * `documentInboxAddress` answered "this document has no inbox", which an application reads + * as zero deposits. `downloadReadCap` below produces the distinction deliberately and says + * why; collapsing it one line later destroyed it. A failure must not read as an absence. + * + * The memo keeps the same asymmetry it always did, and it is now the same asymmetry: an + * absence is remembered, a failure is not (see the note on {@link attempted}), so the next + * caller asks the network again instead of replaying a rejection. */ // @provenance fetchReadCap kind=declared-not-wired level=1 ref=engine/net/src/types.rs:PublicRepoLinkV0 — the type DECLARES the cap is downloaded from the outer overlay if the brokers allow; it is constructed nowhere, both `PinRepo` constructors hard-code `expose_outer: false`, and `ExtTopicSyncReq` is `unimplemented!()` export async function fetchReadCap(docLike: Nuri): Promise { @@ -200,7 +211,10 @@ export async function fetchReadCap(docLike: Nuri): Promise { pending = downloadReadCap(doc); attempted.set(doc, pending); // A failure is not an answer, so it must not be filed as one: drop the memo the - // moment the download rejects, and the next caller reaches the broker again. + // moment the download rejects, and the next caller reaches the broker again. The + // report stays even though the rejection now reaches the caller: this handler runs + // once per download however many callers shared it, and it runs at all when every one + // of them has given up, which is the only trace left in that case. // Attached HERE — once per download, before anybody awaits — so the eviction and the // report each happen exactly once however many callers share this one in-flight // fetch, and happen at all even if every one of them has already given up. The @@ -215,12 +229,24 @@ export async function fetchReadCap(docLike: Nuri): Promise { let cap: ReadCap | null; try { cap = await pending; - } catch { - // Reported and forgotten by the handler attached where the download started, just - // above — not swallowed here. The answer to the caller is the same as for an absence - // (no cap was obtained), but the memo now holds nothing for this document, so the - // next call asks the network instead of replaying this failure. - return false; + } catch (error) { + // The download REJECTED: the repo would not open, the broker did not answer, the read + // threw. Whether this document may be read is UNKNOWN, and that is not what `false` + // says. Rethrown rather than returned so the caller's read fails instead of continuing + // on a document it never obtained anything for — upstream's observable is that a read + // rejects or it does not, and this is the side of it that was missing. + // + // Wrapped so the reason survives the trip: the message a caller would otherwise meet + // is `assertMayReach`'s "you do not hold this document's cap", which is a different + // event calling for a different action (give up, rather than retry). The original is + // on `cause`, and the handler attached where the download started has already logged + // it and dropped the memo, so the next caller asks the network again. + throw new Error( + "[ng-eventually] fetchReadCap: the public store could not be asked for this " + + "document's read cap, so whether it may be read is UNKNOWN — this is not " + + `"the document exposes no cap". ${JSON.stringify(doc)}`, + { cause: error }, + ); } if (cap === null) return false; // Filed for whoever is connected NOW, on every call — the memo spares the round-trip, @@ -266,13 +292,10 @@ async function downloadReadCap(doc: Nuri): Promise { return null; } -/** - * Ask for a SET of documents' caps, in parallel — what a batch read does before it - * decides which documents it may touch. Each fetch is independent and tolerant. - */ -// @provenance fetchReadCaps kind=declared-not-wired level=1 ref=engine/net/src/types.rs:PublicRepoLinkV0 — the batch form of the same unwired mechanism -export async function fetchReadCaps(docs: Nuri[]): Promise { - const unique = [...new Set(docs.filter(Boolean))]; - if (unique.length === 0) return; - await Promise.all(unique.map((d) => fetchReadCap(d))); -} +// `fetchReadCaps(docs)` was REMOVED in `1.0.0-dev.9`. It was the batch form — a +// `Promise.all` over the singular, described as "independent and tolerant" — and it had +// ZERO callers, the last one having gone with `readUnion` in `1.0.0-dev.8`. Keeping it +// would have left an unowned tolerance policy standing over a call that now rejects: +// "each fetch is independent" is exactly the skip-and-carry-on this line of work has +// removed twice. Whoever needs several caps asks for each, at the call site that knows +// what a missing one costs it. Internal throughout, so nothing published moves. diff --git a/packages/polyfill/src/surface/docs.ts b/packages/polyfill/src/surface/docs.ts index 1bdff08..9ae5fe0 100644 --- a/packages/polyfill/src/surface/docs.ts +++ b/packages/polyfill/src/surface/docs.ts @@ -151,6 +151,11 @@ export async function sparqlQuery( // public store gives its cap to whoever asks, and this is a door an application can // reach with nothing but a bare reference. Inert once the cap is held, memoised // otherwise — see public-store.ts. + // + // It REJECTS when the store could not be asked, and that rejection is deliberately not + // caught here. The refusal below would otherwise take its place and report the wrong + // event: "you do not hold this document's cap" for "nobody answered". Both stop the + // read; only one of them tells the reader to try again. if (anchor !== undefined) { await fetchReadCap(anchor); assertMayReach(anchor, "docs.sparqlQuery"); diff --git a/packages/polyfill/src/surface/use-shape-query.ts b/packages/polyfill/src/surface/use-shape-query.ts index 12b2048..1545b41 100644 --- a/packages/polyfill/src/surface/use-shape-query.ts +++ b/packages/polyfill/src/surface/use-shape-query.ts @@ -73,6 +73,38 @@ * bounded by what it exposes today; the day the ORM surfaces a failed start, this is * where it lands. * + * -- A named document is REACHED before the query opens, since `1.0.0-dev.9` -------- + * `scope.graphs` does not only narrow a search: each entry NAMES a document, and naming + * one is the same act `docs.sparqlQuery`'s anchor is. So each named document is put + * through the same two steps that call makes - the (emulated) public store is asked for + * its cap, and the document is refused out loud if the cap is not held - plus the + * cold-start open every other read path in this library crosses (`ensureRepoOpen`). + * + * What it repairs is this surface's own version of the failure it exists against. A named + * document whose cap the holder does not hold used to reach the engine, come back, and be + * emptied by the capability filter below, publishing `isSuccess` with `data: []` - "this + * document holds nothing of that shape" for "you cannot read this document", while the + * anchored read of the SAME document refuses. A named document this session has never + * synced published the same snapshot for a document that is not empty. Both are now + * `isError`, which is what `isError` has always meant here: the current answer is UNKNOWN. + * + * **What is deliberately NOT guarded, and each is an answer rather than a failure:** + * - `graphs: "did:ng:i"` or `""` - the whole local dataset. The application named no + * document, so there is no cap question to ask; the capability filter is the right + * and only answer, exactly as the broker delivering only what your keys open is. + * - `graphs: []` - no objects, by the SDK's own definition. `isSuccess` with `data: []`. + * - a named document held and synced that holds nothing of the shape - `isSuccess` with + * `data: []`, which is the distinction this whole surface exists to publish. + * - the INERT case: before the first cap is issued nothing is enforcing, the public + * store is not asked at all, and `assertMayReach` passes for everything. + * + * A scope naming SEVERAL documents fails as a whole when one of them cannot be reached, + * rather than answering the others. A short list is the same lie as an empty one, and it + * is the tolerance `readUnion` was removed for in `1.0.0-dev.8`. + * + * The barrier is why the open below is asynchronous: it prolongs `isPending`, and nothing + * else about the states moves. + * * -- An observable cannot un-emit: `data` survives an error ----------------- * A one-shot call rejects and is done. An observable has already handed a list to a * subscriber that rendered it, so its failure snapshot has to say something about @@ -84,7 +116,10 @@ import { getCaps, getConfig } from "../shared-wallet/bootstrap"; import { filterReadable } from "../emulated-verifier/read-filter"; -import type { OrmSubscriptionHandle, OrmSubscriptionLike } from "../model/types"; +import { ensureRepoOpen } from "../emulated-verifier/open-repo"; +import { assertMayReach } from "../emulated-verifier/reach"; +import { isNuri } from "../model/nuri"; +import type { Nuri, OrmSubscriptionHandle, OrmSubscriptionLike } from "../model/types"; // The SDK's own types, imported as TYPES (erased at build, so no runtime `@ng-org` // import is added here). The provenance of what is PUBLISHED is recorded in `index.ts`. import type { Scope } from "@ng-org/orm"; @@ -135,6 +170,32 @@ export interface ShapeObservable { refetch(): void; } +/** + * The whole local dataset, as the SDK spells it. `normalizeScope` maps `""` onto this + * very string (`@ng-org/orm`'s `types.ts`), so both forms are the same marker and neither + * names a document. + */ +const WHOLE_DATASET = "did:ng:i"; + +/** + * The DOCUMENTS a scope NAMES - the only entries a capability question can be asked + * about. + * + * Flattened the way the SDK flattens `graphs` (a bare string is one entry), then reduced + * to what identifies a document: the whole-dataset marker is dropped because it names + * none, and so is anything that is not a NextGraph reference - an ordinary IRI names no + * repo this library could hold a cap for, and the engine is left to match it or not. + * Deduplicated, so a scope repeating a document opens it once. + */ +function namedDocuments(scope: Scope): Nuri[] { + const out: Nuri[] = []; + for (const g of [scope.graphs].flat()) { + if (typeof g !== "string" || g === "" || g === WHOLE_DATASET) continue; + if (isNuri(g) && !out.includes(g)) out.push(g); + } + return out; +} + /** * Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one * `scope` - the SDK's own `{ graphs, subjects }`, passed through untouched. See the @@ -287,40 +348,77 @@ export function useShapeQuery( } } + /** + * Cross the barrier on every document the scope NAMES, before the query opens - the + * same two steps `docs.sparqlQuery` takes on its anchor, in the same order. + * + * `ensureRepoOpen` asks the (emulated) public store for the document's cap, and opens + * and syncs the repo when it may be touched; it declines SILENTLY when it may not, + * which is right for a caller that has another way to find out. This one has none, so + * `assertMayReach` says it out loud immediately after - without that, an unheld + * document would simply produce no objects and read as a document holding none. + * + * Sequential rather than parallel, deliberately: the first document that cannot be + * reached is the one reported, and there is no batch policy to own (`ensureReposOpen`, + * the parallel form, was removed in `1.0.0-dev.8`/`.9` for having none worth keeping). + * Each open is memoised per session, so a re-subscribe costs a map hit. + */ + async function reachNamedDocuments(): Promise { + for (const doc of namedDocuments(scope)) { + await ensureRepoOpen(doc); + assertMayReach(doc, "useShapeQuery"); + } + } + /** * Open a shape query for `(shapeType, scope)` and wire its two signals. * * Supersedes whatever was open: the previous generation's callbacks are dropped on - * arrival rather than raced, so a slow answer can never clobber a newer question. + * arrival rather than raced, so a slow answer can never clobber a newer question. The + * barrier above runs first, which is why the body is asynchronous - every step past an + * `await` re-checks that it is still the current generation before touching anything, + * so a `refetch` or a teardown during the barrier leaves no query behind. */ function open(): void { closeQuery(); const gen = ++generation; - let handle: OrmSubscriptionHandle; - try { - handle = shapeQuery()(shapeType, scope); - } catch (error) { - setUnknown(error); - return; - } - query = handle; - const onChange = (): void => publish(handle, gen); - onQueryChange = onChange; - handle.addChangeListener(onChange); - handle.readyPromise.then( - () => { - if (gen !== generation) return; - ready = true; - publish(handle, gen); - }, - (error: unknown) => { - // Nothing upstream rejects this promise today (see the module header); the - // rejection path is wired all the same, because a state nobody handles is how - // a failure becomes silence the day the ORM starts reporting one. + void (async () => { + try { + await reachNamedDocuments(); + } catch (error) { if (gen !== generation) return; setUnknown(error); - }, - ); + return; + } + // Superseded while the barrier was crossing: drop this open rather than leaving a + // subscription nothing will ever close. + if (gen !== generation) return; + let handle: OrmSubscriptionHandle; + try { + handle = shapeQuery()(shapeType, scope); + } catch (error) { + setUnknown(error); + return; + } + query = handle; + const onChange = (): void => publish(handle, gen); + onQueryChange = onChange; + handle.addChangeListener(onChange); + handle.readyPromise.then( + () => { + if (gen !== generation) return; + ready = true; + publish(handle, gen); + }, + (error: unknown) => { + // Nothing upstream rejects this promise today (see the module header); the + // rejection path is wired all the same, because a state nobody handles is how + // a failure becomes silence the day the ORM starts reporting one. + if (gen !== generation) return; + setUnknown(error); + }, + ); + })(); } function start(): void { diff --git a/packages/polyfill/test/document-inbox-surfaces-failure.test.ts b/packages/polyfill/test/document-inbox-surfaces-failure.test.ts new file mode 100644 index 0000000..8920429 --- /dev/null +++ b/packages/polyfill/test/document-inbox-surfaces-failure.test.ts @@ -0,0 +1,101 @@ +/** + * document-inbox-surfaces-failure.test.ts - "this document has no inbox" must be an ANSWER. + * + * -- The defect this pins, and why it is worse than it looks --------------- + * `documentInboxAddress` reads a document's Header branch for the address to deposit at, + * and answers `undefined` when there is none. Two published calls turn that value into a + * statement an application acts on: `inbox.postToDocument` says "its owner never opened + * one", and `inbox.readForDocument` says zero deposits. + * + * Until `1.0.0-dev.9` it answered `undefined` for two things that are not that. The public + * store could not be asked for the document's cap, and `fetchReadCap` reported a breakdown + * with the same `false` a document exposing no cap answers with; or the address read itself + * broke, and a `catch` returned `undefined`. Either way a depositor was told the document + * has no inbox, and a reader was handed an empty list - for a document whose inbox is full. + * That is the failure-as-absence this package has closed roughly eight times. + * + * -- What is under test, and what must NOT move --------------------------- + * The pair is the point, and neither half proves anything alone. A document nobody opened + * an inbox on still answers empty, and still without an error: **an absence is an answer**, + * and a repair that turned it into a failure would only have moved the lie. The tests below + * run BOTH over the same harness, so one cannot be satisfied at the other's expense. + * + * -- The fault is the broker's -------------------------------------------- + * Nothing here reaches into the library to make one of its functions throw. The world is + * built through the surface - Alice writes a public note and opens it for messages - and + * then the fake broker stops answering for one named repo (`wallet-fake._failReadsOn`), + * which is what `resolve_target_for_sparql` does upstream for a repo the verifier does not + * hold: it rejects, and never answers 0 rows. + */ + +import { test, expect, describe, afterAll, beforeEach } from "bun:test"; +import { inbox as inboxSurface, storeRegistry } from "../src/index"; +import { bootPage, forgetEverything, signIn, type Quad } from "./wallet-fake"; +import type { Nuri } from "../src/model/types"; + +/** The broker's own cold start, so the barrier is a real one here too. */ +const COLD = { unsyncedUntilSubscribed: true } as const; + +beforeEach(() => { + forgetEverything(); +}); + +afterAll(() => { + forgetEverything(); +}); + +describe("a document's inbox address says nothing it does not know", () => { + test("a broker that cannot answer for the document is a REJECTION, not 'no inbox'", async () => { + const quads: Quad[] = []; + const fake = bootPage(quads, COLD); + await signIn("alice"); + const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore()); + await storeRegistry.openDocumentInbox(note); + + // Bob holds nothing: the public store is what would serve him the note's cap, and it + // is exactly that round-trip the broker now cannot answer. + await signIn("bob"); + fake._failReadsOn.add(note); + + // Before `1.0.0-dev.9` this resolved to `undefined` and `postToDocument` turned it + // into "its owner never opened one" - a statement about Alice's note, made out of a + // broker outage. Bob would have gone away believing there was nowhere to write. + await expect( + inboxSurface.postToDocument(note, { payload: { text: "j'apporte le cafe" } }), + ).rejects.toThrow(/UNKNOWN/); + }); + + test("the reader is told too: a failed address read does not read as zero deposits", async () => { + const quads: Quad[] = []; + const fake = bootPage(quads, COLD); + await signIn("alice"); + const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore()); + await storeRegistry.openDocumentInbox(note); + + // Alice's own page, so the cap question never arises and `fetchReadCap` is a memo hit: + // what breaks is the ADDRESS read itself, which used to be caught and answered + // `undefined`. This is the second of the two routes, and it needs its own test because + // repairing the first one leaves it untouched. + fake._failReadsOn.add(note); + await expect(inboxSurface.readForDocument(note)).rejects.toThrow(/RepoNotFound/); + await expect(inboxSurface.readSyncedForDocument(note)).rejects.toThrow(/RepoNotFound/); + }); + + test("and a document nobody opened an inbox on still answers EMPTY, and succeeds", async () => { + const quads: Quad[] = []; + bootPage(quads, COLD); + await signIn("alice"); + // Created through the surface and never opened for messages: a genuine absence, on a + // broker answering perfectly well. This must stay a success, or the repair above + // would have swapped one lie for another. + const bare: Nuri = await storeRegistry.createEntityDoc(await storeRegistry.publicStore()); + + expect(await inboxSurface.readForDocument(bare)).toEqual([]); + expect(await inboxSurface.readSyncedForDocument(bare)).toEqual([]); + // And the deposit side says the same thing in its own words - a refusal that names + // the real state, not an outage. + await expect( + inboxSurface.postToDocument(bare, { payload: { text: "hello" } }), + ).rejects.toThrow(/has no inbox/); + }); +}); diff --git a/packages/polyfill/test/public-store.test.ts b/packages/polyfill/test/public-store.test.ts index 9e6f6f8..33bff5f 100644 --- a/packages/polyfill/test/public-store.test.ts +++ b/packages/polyfill/test/public-store.test.ts @@ -201,6 +201,14 @@ test("resetting the caps forgets the memo — a stale yes would hand back what i // transient outage recorded "no capability" for that document until the session ended. // Deposits being consumed, the deposit was retired before its arm read anything — and // every re-deposit sent to repair it met the same memoised `null` and was destroyed too. +// +// SECOND HALF (`1.0.0-dev.9`). That repair kept the two negatives apart in the MEMO and +// still handed the caller one value for both: a failure and an absence were both `false`. +// The tests below said so, and what they were really asserting was the memo's arithmetic +// - which is why every `asksAbout` count here is unchanged. What moved is the ANSWER: a +// download that could not happen now rejects, because three call sites read `false` as +// "this document exposes no cap" and turned a breakdown into a statement about the +// document. A genuine absence still answers `false`, and still only asks once. /** How many times the network was asked ABOUT `nuri` — anchored reads only, so the * connection work happening in the background on other documents does not count. */ @@ -216,7 +224,8 @@ test("a fetch that FAILED is asked again, and the cap arrives once the broker re setCurrentUser("bob"); unreachable.add(PUB); // the broker cannot answer for this document - expect(await fetchReadCap(PUB)).toBe(false); + // NOT `false`: "the store could not be asked" is not "the document exposes no cap". + await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/); expect(asksAbout(sparql_query, PUB)).toBe(1); expect(getCaps().capFor(PUB)).toBeUndefined(); @@ -234,14 +243,16 @@ test("an absence is remembered, a failure is not — the two negatives are not t setCurrentUser("bob"); const absent = "did:ng:o:exposes-nothing" as Nuri; - expect(await fetchReadCap(absent)).toBe(false); + expect(await fetchReadCap(absent)).toBe(false); // an ANSWER, and `false` is what it is expect(await fetchReadCap(absent)).toBe(false); expect(asksAbout(sparql_query, absent)).toBe(1); // read once, and the answer stands const unreached = "did:ng:o:cannot-ask" as Nuri; unreachable.add(unreached); - expect(await fetchReadCap(unreached)).toBe(false); - expect(await fetchReadCap(unreached)).toBe(false); + // Not an answer at all, so it does not get to look like one. The two negatives now + // differ in what the caller SEES, not only in what the memo keeps. + await expect(fetchReadCap(unreached)).rejects.toThrow(/UNKNOWN/); + await expect(fetchReadCap(unreached)).rejects.toThrow(/UNKNOWN/); expect(asksAbout(sparql_query, unreached)).toBe(2); // never answered, so asked every time }); @@ -252,11 +263,18 @@ test("concurrent askers share ONE failing round-trip, and exactly one re-ask fol setCurrentUser("bob"); unreachable.add(PUB); - const answers = await Promise.all([fetchReadCap(PUB), fetchReadCap(PUB), fetchReadCap(PUB)]); - expect(answers).toEqual([false, false, false]); + // `allSettled`, so all three outcomes are asserted rather than only the first: a + // failure reaching one asker and not the others would be a worse bug than the one this + // test was written for. + const answers = await Promise.allSettled([ + fetchReadCap(PUB), + fetchReadCap(PUB), + fetchReadCap(PUB), + ]); + expect(answers.map((a) => a.status)).toEqual(["rejected", "rejected", "rejected"]); expect(asksAbout(sparql_query, PUB)).toBe(1); // one in-flight fetch served all three - expect(await fetchReadCap(PUB)).toBe(false); + await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/); expect(asksAbout(sparql_query, PUB)).toBe(2); // and the failure left nothing behind }); @@ -271,7 +289,7 @@ test("a cap obtained after a failure is filed for whoever is connected THEN", as setCurrentUser("bob"); unreachable.add(PUB); - expect(await fetchReadCap(PUB)).toBe(false); + await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/); unreachable.delete(PUB); setCurrentUser("carol"); @@ -281,3 +299,22 @@ test("a cap obtained after a failure is filed for whoever is connected THEN", as setCurrentUser("bob"); expect(getCaps().capFor(PUB)).toBeUndefined(); // bob only ever met the outage }); + +// The rejection has to be USABLE, or a caller meeting it learns no more than it did from +// `false`: it must name the document it is about, and it must not lose what actually went +// wrong underneath. `cause` is where the broker's own words survive. +test("the rejection names the document and keeps the original failure on `cause`", async () => { + const { unreachable } = inject(); + armEmulation(); + setCurrentUser("bob"); + + const doc = "did:ng:o:outage" as Nuri; + unreachable.add(doc); + const error = await fetchReadCap(doc).then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain(doc); + expect(String((error as Error).cause)).toContain("cannot serve that overlay"); +}); diff --git a/packages/polyfill/test/use-shape-query.test.ts b/packages/polyfill/test/use-shape-query.test.ts index 2e30f75..7996775 100644 --- a/packages/polyfill/test/use-shape-query.test.ts +++ b/packages/polyfill/test/use-shape-query.test.ts @@ -498,6 +498,117 @@ describe("useShapeQuery", () => { unsub(); }); + // -- a NAMED document is reached before the query opens, since `1.0.0-dev.9` --- + // + // `graphs` does not only narrow a search: each entry NAMES a document, and this surface + // used to name one, hand it to the engine, and empty the result with the capability + // filter - publishing `isSuccess` with `data: []`, which is the one snapshot it exists + // to reserve for "this scope holds nothing". The four cases below are the distinct ones: + // the document that cannot be reached, the one that can and is empty, the list where + // only one member fails, and the emulation not yet in force. + + it("(l) a named document the holder holds no capability for is isError, not an empty success", async () => { + const ng = makeFake(); + const orm = makeOrm(); + inject(ng, orm); + setCurrentUser("kate"); + // Creating one document is what puts the emulation in force - the cap registry is + // inert until a first cap exists, and (o) below is the case where it still is. + await createEntityDoc("kate", "protected"); + const theirs = "did:ng:o:someone-elses"; + seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs"); + expect(getCaps().isEnforcing()).toBe(true); + + const obs = useShapeQuery(EventShape, { graphs: [theirs] }); + const unsub = obs.subscribe(() => {}); + await tick(); + await tick(); + + const snap = obs.getSnapshot(); + expect(snap.isError).toBe(true); + expect(snap.isSuccess).toBe(false); + expect(snap.isPending).toBe(false); + expect(String((snap.error as Error).message)).toContain("does not hold this document"); + // RULE 2, and it is the half a filter could never give: the question was not put at + // all. Kate's session never asked the engine about a document she cannot open. + expect(orm.openCount()).toBe(0); + unsub(); + }); + + it("(m) a named document that IS reachable and holds nothing of the shape is isSuccess with data []", async () => { + const ng = makeFake(); + const orm = makeOrm(); + inject(ng, orm); + setCurrentUser("liam"); + // His own, so the barrier passes; nothing of the shape is ever seeded into it. + const mine = await createEntityDoc("liam", "protected"); + expect(getCaps().isEnforcing()).toBe(true); + + const obs = useShapeQuery(EventShape, { graphs: [mine] }); + const unsub = obs.subscribe(() => {}); + await tick(); + await tick(); + + // THE line that must not move. An empty document is a document that answered, and + // the repair above would only have relocated the lie if this had become an error. + const snap = obs.getSnapshot(); + expect(snap.isSuccess).toBe(true); + expect(snap.isError).toBe(false); + expect(snap.isPending).toBe(false); + expect(snap.data).toEqual([]); + unsub(); + }); + + it("(n) one unreachable document in a named list refuses the whole scope", async () => { + const ng = makeFake(); + const orm = makeOrm(); + inject(ng, orm); + setCurrentUser("maya"); + const mine = await createEntityDoc("maya", "protected"); + seedEvent(orm, mine, `${mine}:q:one`, "Mine"); + const theirs = "did:ng:o:not-mayas"; + seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs"); + + const obs = useShapeQuery(EventShape, { graphs: [mine, theirs] }); + const unsub = obs.subscribe(() => {}); + await tick(); + await tick(); + + // Not a short list. Answering "Mine" alone would say the scope holds one object when + // the truth is that half of it was never asked - the tolerance `readUnion` was + // removed for in `1.0.0-dev.8`, arriving by another door. + const snap = obs.getSnapshot(); + expect(snap.isError).toBe(true); + expect(snap.isSuccess).toBe(false); + expect(snap.data).toEqual([]); + expect(orm.openCount()).toBe(0); + unsub(); + }); + + it("(o) INERT: before the first cap exists a named document reads, and nothing refuses", async () => { + const ng = makeFake(); + const orm = makeOrm(); + inject(ng, orm); + setCurrentUser("nora"); + // No document created here, so no cap was ever minted: the emulation is not in force, + // which is the state every early path runs in. A barrier that fired here would break + // all of them. + const early = "did:ng:o:early-doc"; + seedEvent(orm, early, `${early}:q:one`, "Early"); + expect(getCaps().isEnforcing()).toBe(false); + + const obs = useShapeQuery(EventShape, { graphs: [early] }); + const unsub = obs.subscribe(() => {}); + await tick(); + await tick(); + + const snap = obs.getSnapshot(); + expect(snap.isError).toBe(false); + expect(snap.isSuccess).toBe(true); + expect(titles(snap.data)).toEqual(["Early"]); + unsub(); + }); + // -- teardown ------------------------------------------------------------ it("(k) the last unsubscribe closes the shape query", async () => {