fix!: la garde circulaire disparait, et une nouvelle tentative se fait au nom de qui a ete refuse
Troisieme tour de revue adversariale, trois constats, tous reproduits avant correction. 1. La garde etait CIRCULAIRE, et c'etait structurel. `namedDocumentsReachable` demandait si la capacite est DETENUE. Pour un document en store public, le seul canal qui puisse jamais la deposer est `fetchReadCap` - l'appel meme que la garde retenait. Elle attendait une reponse que seule l'action qu'elle bloquait pouvait produire, et toute cette classe restait irrecuperable. La bonne question n'est pas une meilleure garde, c'est de RETENTER. Les deux facons d'etre refuse se reparent par des choses opposees, et le memo les distingue deja : un echec n'est deliberement pas memoise, donc une nouvelle tentative redemande vraiment au reseau - c'est la reparation de cette classe, pas un cout a eviter ; une absence est memoise, donc une nouvelle tentative ne demande a personne et coute un instantane, jusqu'au jour ou la capacite arrive d'ailleurs, seule chose qui repare celle-la. La garde est supprimee : le module est plus petit qu'au tour precedent. 2. La reouverture faisait un ACCES sous la mauvaise identite. Republier REFILTRE une requete existante ; rouvrir EMET un abonnement et une requete au store public pour un document que cet abonne a nomme. C'est un acte neuf, pas un ancien qui ressort, et l'etat final etait `isSuccess` avec `data: []` pour un document illisible - le mensonge que cette tache supprime, par la porte du correctif. `refusedFor` retient QUI a ete refuse ; on ne retente que tant que c'est encore lui le connecte. Etabli plutot qu'affirme : une application ne peut pas changer d'identite en cours de session. `ensureIdentity` est le seul appel publie qui en etablit une, et `resolveIdentity` commence par `getCurrentUser()`. Corrige quand meme. 3. `Promise.all` court-circuite a la premiere rejection ARRIVEE, et un fetch qui rejette n'attend pas la barriere de synchronisation : il double un refus portant sur un document nomme avant lui. Le comportement est corrige plutot que le commentaire - `allSettled` puis parcours dans l'ordre du scope. Et le quatrieme controle e2e est retire, pas repare : il passait avec toute la machinerie de reprise supprimee. Un controle qui ne peut echouer pour aucune regression de ce qu'il garde se lit comme de la couverture et vaut moins que rien.
This commit is contained in:
@@ -60,6 +60,9 @@ interface SubRec { nuri: string; cb: (r: unknown) => void }
|
||||
* creation the page does around the read. It is NOT what answers the read any more.
|
||||
*/
|
||||
function makeFake(options: { pushState?: boolean } = {}) {
|
||||
/** Anchors whose anchored read throws. Mutable after boot, so a suite builds a healthy
|
||||
* world first and breaks only the one call it is about. */
|
||||
const unreachable = new Set<string>();
|
||||
// A broker that never pushes the first `State` is the pathological case the bootstrap
|
||||
// open's bounded fallback exists for - not the ordinary one, which CONTRACT 3 in
|
||||
// `e2e/` measures at about a millisecond. It is what makes the barrier's cost visible
|
||||
@@ -103,6 +106,10 @@ function makeFake(options: { pushState?: boolean } = {}) {
|
||||
});
|
||||
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
// The broker cannot answer for this repo right now - an outage, a repo this session
|
||||
// has not synced, a rejected read. It REJECTS, as `resolve_target_for_sparql` does
|
||||
// for a repo the verifier does not hold, and never answers 0 rows.
|
||||
if (unreachable.has(a[3] as string)) throw new Error("RepoNotFound (emulated outage)");
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
// Pointer SELECT (store-root -> doc-shim).
|
||||
@@ -136,7 +143,7 @@ function makeFake(options: { pushState?: boolean } = {}) {
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
for (const marker of ["inboxCap", "readCap", "contains"] as const) {
|
||||
for (const marker of ["inboxCap", "readCap", "contains", "exposedReadCap"] as const) {
|
||||
if (query.includes(`<urn:ng-eventually:shim:${marker}>`)) {
|
||||
const key = marker === "contains" ? "e" : "c";
|
||||
const bindings = quads
|
||||
@@ -158,7 +165,7 @@ function makeFake(options: { pushState?: boolean } = {}) {
|
||||
return () => {};
|
||||
});
|
||||
|
||||
return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads };
|
||||
return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads, _unreachable: unreachable };
|
||||
}
|
||||
|
||||
function inject(ng: ReturnType<typeof makeFake>, orm: FakeOrm) {
|
||||
@@ -706,7 +713,7 @@ describe("useShapeQuery", () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(s) a cap for some OTHER document does not re-open a scope that is still unreachable", async () => {
|
||||
it("(s) a re-attempt that cannot succeed spends NO round-trip: an absence is memoised", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
@@ -720,32 +727,40 @@ describe("useShapeQuery", () => {
|
||||
const unsub = obs.subscribe(() => {
|
||||
notes += 1;
|
||||
});
|
||||
await tick();
|
||||
await tick();
|
||||
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
|
||||
/** How many times the network was asked ABOUT this document - anchored reads only. */
|
||||
const asks = (): number =>
|
||||
ng.sparql_query.mock.calls.filter((c) => c[3] === note).length;
|
||||
const afterRefusal = asks();
|
||||
const notesAfterRefusal = notes;
|
||||
|
||||
// A keyring change that says nothing about THIS scope - every connection files
|
||||
// several, and an inbox delivery for another document files one more. Re-opening on
|
||||
// each of them would re-run the barrier, re-ask the network for any document whose
|
||||
// fetch had FAILED (a failure is deliberately not memoised) and re-publish the same
|
||||
// error to every subscriber, for as long as the session lasts.
|
||||
// What this replaced, and why. An earlier version gated the re-attempt on "does the
|
||||
// holder hold every named document's cap yet", so that an unrelated cap would not
|
||||
// buy a round-trip. That gate was CIRCULAR for a document in a public store - the
|
||||
// only channel that can ever file its cap is the very fetch the gate was blocking -
|
||||
// and an adversarial pass proved the class unrecoverable. The gate is gone: every
|
||||
// keyring change re-attempts, for the holder that was refused.
|
||||
//
|
||||
// The count of NOTIFICATIONS is what separates the targeted retry from the
|
||||
// indiscriminate one: re-opening republishes, and republishing wakes every view
|
||||
// bound to this observable. Asserting on the ORM instead proves nothing here - the
|
||||
// barrier refuses before the engine is ever asked, either way.
|
||||
getCaps().learn(mintCap("did:ng:o:something-else"));
|
||||
await tick();
|
||||
await tick();
|
||||
// What makes that affordable is the memo, and it is worth pinning rather than
|
||||
// assuming: the network ANSWERED here (this document exposes no cap), and an answer
|
||||
// is remembered. So the re-attempts below re-run the barrier and ask nobody. A
|
||||
// failure is the other case - deliberately not memoised, so it IS re-asked, which is
|
||||
// exactly the repair for it (see (v)).
|
||||
for (let round = 0; round < 3; round++) {
|
||||
getCaps().learn(mintCap(`did:ng:o:unrelated-${round}`));
|
||||
for (let i = 0; i < 5; i++) await tick();
|
||||
}
|
||||
|
||||
// BOTH halves, or this test is vacuous: a re-attempt that never ran would also leave
|
||||
// the count untouched. The republication is what proves the barrier was re-run.
|
||||
expect(notes).toBeGreaterThan(notesAfterRefusal);
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
expect(notes).toBe(notesAfterRefusal);
|
||||
expect(asks()).toBe(afterRefusal);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- what the barrier COSTS, which is not nothing -------------------------
|
||||
|
||||
it("(t) named documents cross the barrier TOGETHER, so N that never sync cost one wait, not N", async () => {
|
||||
const ng = makeFake({ pushState: false });
|
||||
const orm = makeOrm();
|
||||
@@ -808,6 +823,99 @@ describe("useShapeQuery", () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- the three the second adversarial pass found -------------------------
|
||||
|
||||
it("(v) a PUBLIC document whose FETCH failed is asked again when the keyring moves", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("alice");
|
||||
const pub = await createEntityDoc("alice", "public");
|
||||
seedEvent(orm, pub, `${pub}:q:one`, "Public");
|
||||
|
||||
setCurrentUser("bob");
|
||||
// Bob holds nothing, and the public store is the ONLY channel that can ever hand him
|
||||
// this document's cap - which is exactly what the broker now cannot answer.
|
||||
ng._unreachable.add(pub);
|
||||
const obs = useShapeQuery(EventShape, { graphs: [pub] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
|
||||
// The broker heals, and an ordinary cap arrival follows - a connection restoring a
|
||||
// key, an inbox delivery for some other document. The first gate written for this
|
||||
// asked "does the holder hold every named document's cap yet", and for a PUBLIC
|
||||
// document only the failed fetch can ever make that true: the gate waited on an
|
||||
// answer that only the action it gated could produce.
|
||||
ng._unreachable.delete(pub);
|
||||
getCaps().learn(mintCap("did:ng:o:unrelated-arrival"));
|
||||
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(titles(snap.data)).toEqual(["Public"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(w) a refused scope never re-opens under a DIFFERENT identity", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("alice");
|
||||
const hers = await createEntityDoc("alice", "protected");
|
||||
seedEvent(orm, hers, `${hers}:q:one`, "Hers");
|
||||
|
||||
setCurrentUser("bob");
|
||||
const obs = useShapeQuery(EventShape, { graphs: [hers] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
|
||||
// The page becomes Alice. No application can do this - `ensureIdentity` is the only
|
||||
// published call that settles an identity and it returns the one already set
|
||||
// (`access-gate.resolveIdentity` reads `getCurrentUser()` first) - but the re-open is
|
||||
// a NEW access, not a re-filtering of an existing one, and a new access must never be
|
||||
// issued on behalf of somebody who did not ask for it.
|
||||
setCurrentUser("alice");
|
||||
getCaps().learn(mintCap("did:ng:o:unrelated-arrival"));
|
||||
for (let i = 0; i < 10; i++) await tick();
|
||||
expect(orm.openCount()).toBe(0); // nothing was subscribed or queried as Alice
|
||||
|
||||
// ...and Bob's view must not come back as a SUCCESS holding nothing, which is the lie
|
||||
// this whole task exists to remove, arriving by the repair's own door.
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(mintCap("did:ng:o:unrelated-arrival-2"));
|
||||
for (let i = 0; i < 10; i++) await tick();
|
||||
expect(obs.getSnapshot().isSuccess).toBe(false);
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(x) the failure reported is the FIRST named document that failed, in scope order", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("alice");
|
||||
const hers = await createEntityDoc("alice", "protected");
|
||||
const alsoHers = await createEntityDoc("alice", "public");
|
||||
|
||||
setCurrentUser("bob");
|
||||
// Two named documents failing for two DIFFERENT reasons. The first answers and holds
|
||||
// no cap for Bob (a refusal); the second cannot be asked at all (a rejection, which
|
||||
// arrives first because it does not wait for the sync barrier). Running the opens
|
||||
// together and letting the first rejection win would report the SECOND document.
|
||||
ng._unreachable.add(alsoHers);
|
||||
const obs = useShapeQuery(EventShape, { graphs: [hers, alsoHers] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
||||
|
||||
const message = String((obs.getSnapshot().error as Error).message);
|
||||
expect(message).toContain(hers);
|
||||
expect(message).not.toContain(alsoHers);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- teardown ------------------------------------------------------------
|
||||
|
||||
it("(k) the last unsubscribe closes the shape query", async () => {
|
||||
|
||||
Reference in New Issue
Block a user