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
+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 {