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
@@ -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 () => {