fix!: une lecture qui n'atteint pas son document echoue, elle ne repond plus vide

`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.
This commit is contained in:
Sylvain Duchesne
2026-09-21 15:00:01 +02:00
parent 6cd96b0182
commit 75313f75a2
8 changed files with 463 additions and 60 deletions
@@ -302,6 +302,14 @@ export async function restoreOwnCaps(holderKey: string): Promise<void> {
*
* **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<Nuri | undefined> {
@@ -313,7 +321,9 @@ export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>
// …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<Nuri | undefined>
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;
}
@@ -188,8 +188,16 @@ async function syncSession(): Promise<void> {
* `"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<vo
// the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK
// before deciding whether we may touch it, or the answer would be "no" purely for
// want of asking, and a bare reference to a public document would never suffice.
// Memoised and inert once the cap is held (see public-store.ts).
// Memoised and inert once the cap is held (see public-store.ts). REJECTS when the
// store could not be asked at all, which is deliberately not the same event as "this
// document exposes no cap": the open has not happened and the caller must not read as
// though it had.
if (holderKey === undefined) await fetchReadCap(nuri);
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
// pulls its state. A user that holds no cap for it has no business asking.
@@ -181,10 +181,21 @@ export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise<void> {
* 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<boolean> {
@@ -200,7 +211,10 @@ export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
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<boolean> {
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<ReadCap | null> {
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<void> {
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.
+5
View File
@@ -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");
+123 -25
View File
@@ -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<T extends BaseType = BaseType> {
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<T extends BaseType = BaseType>(
}
}
/**
* 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<void> {
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 {
@@ -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/);
});
});
+45 -8
View File
@@ -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");
});
@@ -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 () => {