fix!: un scope nomme refuse se rouvre quand la cle arrive, et la barriere cesse d-etre serie
Revue adversariale : la barriere livree au tour precedent rendait le refus
TERMINAL. Le signal du trousseau s'ecrit `if (query !== null) publish(...)`, parce
qu'avant la barriere il n'y avait qu'une seule raison de n'avoir pas de requete -
rien n'avait encore ete tente - et qu'une cle qui arrive ne pouvait changer que ce
qu'une requete OUVERTE avait le droit de montrer. La barriere en a ajoute une
seconde : elle abandonne AVANT que la requete existe, donc le seul canal qui fait
converger une cle tardive passait a cote de l'etat qui en avait le plus besoin.
Une page qui s'abonne pendant que son trousseau se restaure, ou un document
partage sous les yeux de son destinataire, publiait `isError` et y restait. Ce
n'est pas une surface plus stricte, c'est une pire : echanger un vide mensonger
contre une erreur bloquee ne repare rien, et cela casse une garantie que ce paquet
publie.
Le signal rouvre donc un scope refuse, et seulement quand le refus est leve
(`namedDocumentsReachable`). Le garde-fou porte autant que la reouverture :
rouvrir a chaque changement de trousseau rejouerait la barriere pour chaque cle
qu'une connexion depose, redemanderait au reseau tout document dont le
telechargement avait ECHOUE - un echec n'est deliberement pas memoise - et
republierait la meme erreur a chaque abonne. La variante indiscriminee casse deux
tests, dont celui de demontage qui preexistait.
La barriere passe en parallele. Elle etait serie, donc N documents qui ne poussent
jamais de `State` coutaient N replis bornes, 8s chacun en production. Les refus
restent lus dans l'ordre du scope apres coup.
Et ce qui a laisse passer les deux : l'e2e ne parcourait pas ce chemin. Les deux
journeys `useShapeQuery` lisent `{graphs: "did:ng:i"}`, donc 43/43 n'a jamais rien
prouve sur la barriere. Quatre controles ajoutes sur un scope NOMME, contre le
vrai broker : la proprietaire lit son document, un porteur de rien recoit
`isError` et jamais un succes vide, un document PUBLIC s'ouvre pour lui depuis la
seule reference via la requete de la barriere, et une cle qui tombe pour un AUTRE
document laisse le scope refuse en place.
This commit is contained in:
@@ -989,6 +989,73 @@ const identity = new IdentityStore(
|
||||
if (rec) rec.unsub();
|
||||
setCurrentUser(null);
|
||||
},
|
||||
/**
|
||||
* NAMED-SCOPE seed. Under a fresh identity, create TWO entity documents carrying the
|
||||
* shape's type - one PROTECTED, one PUBLIC - and seed each with a distinguishable
|
||||
* title. Returns the owner and both references, which is all any of the three named
|
||||
* subscriptions below is given.
|
||||
*
|
||||
* Why this exists at all: until `1.0.0-dev.9` every `useShapeQuery` journey in this
|
||||
* suite read `{ graphs: "did:ng:i" }`, so the scope axis that NAMES a document was
|
||||
* measured nowhere against the real broker - and the barrier that names introduced
|
||||
* shipped with no live coverage. A suite that is green on a path it never walks is
|
||||
* worse than one that is red.
|
||||
*/
|
||||
async useShapeQueryNamedSeed(cls: string) {
|
||||
registryInternals.resetRegistryCache();
|
||||
const owner = "@wsn-" + Date.now();
|
||||
setCurrentUser(owner);
|
||||
const s = await sessionReady;
|
||||
const prot = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
await docs.sparqlUpdate(
|
||||
s.session_id,
|
||||
`INSERT DATA { <${prot}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "named-protected" }`,
|
||||
prot,
|
||||
);
|
||||
// PUBLIC, so its ReadCap is exposed on its Header branch at creation - the one
|
||||
// channel by which a bare reference alone opens a document, and the acquisition the
|
||||
// barrier now performs on this call.
|
||||
const pub = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await docs.sparqlUpdate(
|
||||
s.session_id,
|
||||
`INSERT DATA { <${pub}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "named-public" }`,
|
||||
pub,
|
||||
);
|
||||
setCurrentUser(null);
|
||||
return { owner, prot, pub };
|
||||
},
|
||||
/**
|
||||
* Subscribe a NAMED scope under `who` - the owner, or somebody holding nothing.
|
||||
*
|
||||
* The identity is set here and left set for the probe's lifetime, like the two
|
||||
* journeys above: `useShapeQuery` asks the keyring about whoever is connected on every
|
||||
* read, exactly as an application with one stable identity per page does.
|
||||
* `useShapeQuerySnapshot` and `useShapeQueryStop` serve this probe too.
|
||||
*/
|
||||
useShapeQueryNamedSubscribe(handle: string, who: string, doc: string, cls: string) {
|
||||
registryInternals.resetRegistryCache();
|
||||
setCurrentUser(who);
|
||||
const obs = useShapeQuery(wsShape(cls), { graphs: [doc] }) as ShapeObservable;
|
||||
const unsub = obs.subscribe(() => {});
|
||||
const initial = obs.getSnapshot();
|
||||
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
|
||||
return {
|
||||
initial: {
|
||||
isPending: initial.isPending,
|
||||
isSuccess: initial.isSuccess,
|
||||
isError: initial.isError,
|
||||
},
|
||||
};
|
||||
},
|
||||
/** The error message a named scope published, so the runner can tell the refusal from
|
||||
* any other failure rather than accepting `isError` as such. */
|
||||
useShapeQueryError(handle: string) {
|
||||
const rec = (window as any).__sdk._wsProbes[handle];
|
||||
if (!rec) return null;
|
||||
const snap: ShapeQuery = rec.obs.getSnapshot();
|
||||
return snap.error === undefined ? null : String((snap.error as Error)?.message ?? snap.error);
|
||||
},
|
||||
|
||||
/**
|
||||
* EMPTY-scope proof: a brand-new identity with NO entity docs at all. `useShapeQuery`
|
||||
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
|
||||
|
||||
@@ -441,6 +441,99 @@ async function main(): Promise<void> {
|
||||
await sdk(frame, "useShapeQueryStop", h);
|
||||
});
|
||||
|
||||
// The NAMED half of the scope axis, which this suite did not walk until
|
||||
// `1.0.0-dev.9` - both journeys above read `{ graphs: "did:ng:i" }`, so the barrier
|
||||
// that `useShapeQuery` now crosses for every document a scope NAMES had no live
|
||||
// coverage at all. Three readings of the same two documents, one per outcome: the
|
||||
// owner reads hers, somebody holding nothing is REFUSED rather than told the
|
||||
// document is empty, and the same somebody opens the PUBLIC one from nothing but its
|
||||
// reference - which is the barrier's own fetch reaching this broker.
|
||||
await step("useShapeQuery: a NAMED scope reads, refuses, and opens a public document from a bare reference", async () => {
|
||||
const CLS = "urn:e2e:ws:Event";
|
||||
const seed = await sdk<any>(frame, "useShapeQueryNamedSeed", CLS);
|
||||
const stranger = "@wsn-stranger-" + Date.now();
|
||||
|
||||
const own = "named-own" + Date.now();
|
||||
await sdk<any>(frame, "useShapeQueryNamedSubscribe", own, seed.owner, seed.prot, CLS);
|
||||
await frame.waitForFunction(
|
||||
(hh) => {
|
||||
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
|
||||
return s && (s.isSuccess || s.isError);
|
||||
},
|
||||
own,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const ownSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", own);
|
||||
check(
|
||||
"the owner naming her own document reads it (the barrier does not break a live named read)",
|
||||
ownSnap.isSuccess && !ownSnap.isError && ownSnap.titles.includes("named-protected"),
|
||||
`snap=${JSON.stringify(ownSnap)}`,
|
||||
);
|
||||
await sdk(frame, "useShapeQueryStop", own);
|
||||
|
||||
const refused = "named-refused" + Date.now();
|
||||
await sdk<any>(frame, "useShapeQueryNamedSubscribe", refused, stranger, seed.prot, CLS);
|
||||
await frame.waitForFunction(
|
||||
(hh) => {
|
||||
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
|
||||
return s && (s.isSuccess || s.isError);
|
||||
},
|
||||
refused,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const refusedSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", refused);
|
||||
const refusedWhy = await sdkGet<any>(frame, "useShapeQueryError", refused);
|
||||
// The whole point of the change: NOT `isSuccess` with `data: []`, which is what
|
||||
// this same call published before `1.0.0-dev.9` - the capability filter emptied
|
||||
// the result and the snapshot said the document holds nothing of that shape.
|
||||
check(
|
||||
"a named document the holder cannot reach is isError, never an empty success",
|
||||
refusedSnap.isError && !refusedSnap.isSuccess && refusedSnap.dataLen === 0 &&
|
||||
typeof refusedWhy === "string" && refusedWhy.includes("does not hold this document"),
|
||||
`snap=${JSON.stringify(refusedSnap)} why=${String(refusedWhy).slice(0, 80)}`,
|
||||
);
|
||||
// Left OPEN on purpose for the last reading below: a refused scope standing while
|
||||
// a cap for some OTHER document lands is exactly what the keyring signal must not
|
||||
// act on. (`useShapeQueryStop` would also clear the identity, which is why it is
|
||||
// not called here - the next subscribe is the SAME holder.)
|
||||
|
||||
const publicRef = "named-public" + Date.now();
|
||||
await sdk<any>(frame, "useShapeQueryNamedSubscribe", publicRef, stranger, seed.pub, CLS);
|
||||
await frame.waitForFunction(
|
||||
(hh) => {
|
||||
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
|
||||
return s && (s.isSuccess || s.isError);
|
||||
},
|
||||
publicRef,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const publicSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", publicRef);
|
||||
// The acquisition the barrier newly performs on this call: the same identity that
|
||||
// was just refused the protected document opens the public one holding nothing but
|
||||
// its reference, because the barrier asks the public store and this broker answers.
|
||||
check(
|
||||
"a public document named by a holder of nothing opens, through the barrier's own fetch",
|
||||
publicSnap.isSuccess && !publicSnap.isError && publicSnap.titles.includes("named-public"),
|
||||
`snap=${JSON.stringify(publicSnap)}`,
|
||||
);
|
||||
|
||||
// That fetch FILED a cap, which fires `CapRegistry.onChange` - the channel a
|
||||
// refused scope is re-opened on since `1.0.0-dev.9`, because the barrier aborts
|
||||
// before any query exists and `publish` has nothing to re-filter. It has to be
|
||||
// TARGETED: this cap says nothing about the protected document, so the refusal
|
||||
// stands rather than buying a round-trip per cap for the rest of the session.
|
||||
// The positive direction - the cap for the refused document itself arriving -
|
||||
// needs a second actor delivering it, and lives in the applicative suite.
|
||||
const afterOtherCap = await sdkGet<any>(frame, "useShapeQuerySnapshot", refused);
|
||||
check(
|
||||
"a cap landing for ANOTHER document leaves a refused scope refused, not churning",
|
||||
afterOtherCap.isError && !afterOtherCap.isSuccess && afterOtherCap.dataLen === 0,
|
||||
`snap=${JSON.stringify(afterOtherCap)}`,
|
||||
);
|
||||
await sdk(frame, "useShapeQueryStop", refused);
|
||||
await sdk(frame, "useShapeQueryStop", publicRef);
|
||||
});
|
||||
|
||||
// ── caps / read-filter (in-memory cap model) ────────────────────────────
|
||||
console.log("\n── caps / read-filter (in-memory cap model) ──");
|
||||
await step("read-filter: you read what your keyring holds, nothing else", async () => {
|
||||
|
||||
@@ -105,6 +105,25 @@
|
||||
* The barrier is why the open below is asynchronous: it prolongs `isPending`, and nothing
|
||||
* else about the states moves.
|
||||
*
|
||||
* -- A refusal that could not be re-tried would be worse than the defect ------------
|
||||
* The first version of this barrier was TERMINAL, and an adversarial pass found it. The
|
||||
* keyring signal below is written as `if (query !== null) publish(...)` because until the
|
||||
* barrier existed there was only ever one reason to have no query - nothing had been
|
||||
* tried yet - and a cap arriving could only ever change what an OPEN query was allowed to
|
||||
* show. The barrier added a second reason: it aborts BEFORE the query is created, so the
|
||||
* one channel that makes a late cap converge went straight past the state that needed it
|
||||
* most. A page subscribing while its keyring was still being restored, or a document
|
||||
* shared with someone while they watched, published `isError` and stayed there for the
|
||||
* life of the view. Trading a silent empty answer for a stuck error is not a stricter
|
||||
* surface, it is a worse one - and it broke a guarantee this package publishes, which
|
||||
* `emulated-verifier/read-filter.ts` and `surface/inbox.ts` both restate.
|
||||
*
|
||||
* So the signal re-OPENS a refused scope, and does it only when the refusal has actually
|
||||
* been lifted ({@link namedDocumentsReachable}). Re-opening on any keyring change would
|
||||
* re-run the barrier for every cap a connection files - re-asking the network for any
|
||||
* document whose fetch had FAILED, since a failure is deliberately not memoised, and
|
||||
* re-publishing the same error to every subscriber each time.
|
||||
*
|
||||
* -- 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
|
||||
@@ -117,7 +136,7 @@
|
||||
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
|
||||
import { filterReadable } from "../emulated-verifier/read-filter";
|
||||
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
|
||||
import { assertMayReach } from "../emulated-verifier/reach";
|
||||
import { assertMayReach, mayReach } 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`
|
||||
@@ -239,6 +258,16 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
/** Has the CURRENT generation's initial payload arrived? Gates every publication:
|
||||
* a change signal that beats `readyPromise` must not be read as an answer. */
|
||||
let ready = false;
|
||||
/**
|
||||
* Why there is no query in force: the BARRIER refused this scope, rather than the
|
||||
* shape query failing to open or nothing having been tried yet.
|
||||
*
|
||||
* The distinction is what the keyring signal needs. A cap arriving can lift a refusal
|
||||
* and can lift nothing else - a `configure` with no `ormSubscription` is not going to
|
||||
* be fixed by one - so re-opening on any keyring change while any error stood would
|
||||
* churn for a misconfiguration that no amount of retrying repairs.
|
||||
*/
|
||||
let barrierRefused = false;
|
||||
|
||||
function emit(): void {
|
||||
for (const l of listeners) {
|
||||
@@ -362,16 +391,36 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
* `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).
|
||||
* The opens run TOGETHER and the refusals are read afterwards, in scope order. A first
|
||||
* pass ran them one at a time so that the first unreachable document would be the one
|
||||
* reported; that determinism is preserved by the loop below, while the cost of the
|
||||
* serial form was not acceptable - each open waits on the sync barrier, bounded by
|
||||
* `OPEN_TIMEOUT_MS` (8s) for a document that never pushes a `State`, so a scope naming
|
||||
* N of them paid N timeouts before answering anything. `Promise.all` attaches a handler
|
||||
* to every one of them, so a rejection never escapes as an unhandled one, and the opens
|
||||
* that did succeed are memoised per session exactly as they would have been.
|
||||
*
|
||||
* 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");
|
||||
}
|
||||
const docs = namedDocuments(scope);
|
||||
if (docs.length === 0) return;
|
||||
await Promise.all(docs.map((doc) => ensureRepoOpen(doc)));
|
||||
for (const doc of docs) assertMayReach(doc, "useShapeQuery");
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the holder now reach every document the scope NAMES? The precondition for
|
||||
* re-opening a scope the barrier refused - and the whole of it.
|
||||
*
|
||||
* Synchronous and cap-only, so it asks nothing of the network: it answers "has the
|
||||
* thing that blocked us been lifted", which is exactly what a keyring change can have
|
||||
* done and the only question worth spending a re-open on. A scope that is still
|
||||
* unreachable is left alone, so a connection filing a dozen caps does not buy a dozen
|
||||
* round-trips for a view none of them was about.
|
||||
*/
|
||||
function namedDocumentsReachable(): boolean {
|
||||
return namedDocuments(scope).every((doc) => mayReach(doc));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,11 +435,13 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
function open(): void {
|
||||
closeQuery();
|
||||
const gen = ++generation;
|
||||
barrierRefused = false;
|
||||
void (async () => {
|
||||
try {
|
||||
await reachNamedDocuments();
|
||||
} catch (error) {
|
||||
if (gen !== generation) return;
|
||||
barrierRefused = true;
|
||||
setUnknown(error);
|
||||
return;
|
||||
}
|
||||
@@ -435,7 +486,17 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
// ROTATION uses too, which is why keeping an access needs no subscription
|
||||
// obligation on the consumer's side.
|
||||
capsUnsub = getCaps().onChange(() => {
|
||||
if (query !== null) publish(query, generation);
|
||||
if (query !== null) {
|
||||
publish(query, generation);
|
||||
return;
|
||||
}
|
||||
// No query in force. Since `1.0.0-dev.9` there is a second way to be here, and it
|
||||
// is the one this signal exists for: the BARRIER refused the scope, so the query
|
||||
// was never created and there is nothing for `publish` to re-filter. A view left
|
||||
// like that would be TERMINAL - the very case this channel was added to prevent,
|
||||
// arriving by a door that did not exist when the channel was written. So re-open,
|
||||
// and only when the refusal has actually been lifted.
|
||||
if (barrierRefused && namedDocumentsReachable()) open();
|
||||
});
|
||||
open();
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ import { configure } from "../src/index";
|
||||
import { configureStoreRegistry, setCurrentUser, getCaps } from "../src/shared-wallet/bootstrap";
|
||||
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
|
||||
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
||||
import { resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
|
||||
import { mintCap } from "../src/emulated-verifier/caps";
|
||||
import { makeOrm, type FakeOrm } from "./orm-fake";
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
import type { NgLike, UseShapeLike } from "../src/model/types";
|
||||
@@ -58,7 +59,12 @@ interface SubRec { nuri: string; cb: (r: unknown) => void }
|
||||
* The stateful fake `ng` - the shim account, the per-scope index and the document
|
||||
* creation the page does around the read. It is NOT what answers the read any more.
|
||||
*/
|
||||
function makeFake() {
|
||||
function makeFake(options: { pushState?: boolean } = {}) {
|
||||
// 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
|
||||
// without waiting for it.
|
||||
const pushState = options.pushState !== false;
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
const subs: SubRec[] = [];
|
||||
@@ -148,7 +154,7 @@ function makeFake() {
|
||||
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
||||
subs.push({ nuri, cb });
|
||||
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
|
||||
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||
if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
@@ -657,6 +663,151 @@ describe("useShapeQuery", () => {
|
||||
expect(orm.openCount()).toBe(0);
|
||||
});
|
||||
|
||||
// -- the cap that arrives LATE, which is the ordinary case --------------
|
||||
//
|
||||
// A page reload, a `settle` still in flight, an inbox delivery landing while the
|
||||
// person is looking at the screen: in all three the view is subscribed BEFORE the
|
||||
// keyring holds what it needs. The contract promises that such a view converges by
|
||||
// itself - "a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and
|
||||
// publishes the document it now opens" - and `CapRegistry.onChange` is the channel.
|
||||
// A refusal that cannot be re-tried on that signal is not a stricter answer, it is a
|
||||
// dead view.
|
||||
|
||||
it("(r) a NAMED document whose cap arrives after the subscribe converges, it does not stay isError", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
// Alice writes the note. Bob is the one reading, and he holds nothing yet - the
|
||||
// state a page has while its connection work is still restoring the keyring.
|
||||
setCurrentUser("alice");
|
||||
const note = await createEntityDoc("alice", "protected");
|
||||
seedEvent(orm, note, `${note}:q:one`, "Shared");
|
||||
|
||||
setCurrentUser("bob");
|
||||
expect(getCaps().isEnforcing()).toBe(true);
|
||||
const obs = useShapeQuery(EventShape, { graphs: [note] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
// Honest for now: he cannot read it, and being told so beats an empty success.
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
|
||||
// ...and now the cap lands, exactly as the applied share does it
|
||||
// (`branch-registers.addLink` for the connected holder, `inbox-dispatch` for a named
|
||||
// one): filed into the keyring, which fires `CapRegistry.onChange`.
|
||||
getCaps().learn(mintCap(note));
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(titles(snap.data)).toEqual(["Shared"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(s) a cap for some OTHER document does not re-open a scope that is still unreachable", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("alice");
|
||||
const note = await createEntityDoc("alice", "protected");
|
||||
seedEvent(orm, note, `${note}:q:one`, "Shared");
|
||||
|
||||
setCurrentUser("bob");
|
||||
const obs = useShapeQuery(EventShape, { graphs: [note] });
|
||||
let notes = 0;
|
||||
const unsub = obs.subscribe(() => {
|
||||
notes += 1;
|
||||
});
|
||||
await tick();
|
||||
await tick();
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
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.
|
||||
//
|
||||
// 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();
|
||||
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
expect(notes).toBe(notesAfterRefusal);
|
||||
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();
|
||||
inject(ng, orm);
|
||||
setOpenTimeoutForTests(40); // after `inject`, which resets it to the 8s default
|
||||
setCurrentUser("quinn");
|
||||
const a = await createEntityDoc("quinn", "protected");
|
||||
const b = await createEntityDoc("quinn", "protected");
|
||||
const c = await createEntityDoc("quinn", "protected");
|
||||
seedEvent(orm, a, `${a}:q:one`, "A");
|
||||
seedEvent(orm, b, `${b}:q:one`, "B");
|
||||
seedEvent(orm, c, `${c}:q:one`, "C");
|
||||
const named: string[] = [a, b, c];
|
||||
const subscribesTo = (): number =>
|
||||
ng.doc_subscribe.mock.calls.filter((call) => named.includes(String(call[0]))).length;
|
||||
expect(subscribesTo()).toBe(0);
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: [a, b, c] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
|
||||
// The deterministic form of "the waits overlap": all three opens are in flight
|
||||
// before any of them has answered. A serial barrier would show exactly one here and
|
||||
// pay the bounded fallback three times over before publishing anything - which on
|
||||
// the production timeout is 24 seconds of `isPending` for a scope naming three
|
||||
// documents that never push.
|
||||
expect(subscribesTo()).toBe(3);
|
||||
|
||||
// ...and it still answers, once the fallbacks have fired.
|
||||
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["A", "B", "C"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(u) INERT is not untouched: no refusal, but the cold-start open is real and is paid", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("rex");
|
||||
const early = "did:ng:o:inert-named";
|
||||
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();
|
||||
|
||||
// Nothing REFUSES - that half is genuinely inert, since `mayReach` answers true for
|
||||
// everything before the first cap exists. But the open is not: `ensureRepoOpen` runs
|
||||
// whatever the registry is enforcing, and issues a real `doc_subscribe` held for the
|
||||
// session. That is deliberate and it is the cold-start heal - without it a named
|
||||
// document this session has never synced answers `isSuccess` with `data: []`, the
|
||||
// very lie the barrier is here to remove - but it is a CHANGE, and a scope naming a
|
||||
// document now waits on that barrier where it used to open the query at once.
|
||||
expect(ng.doc_subscribe.mock.calls.some((call) => call[0] === early)).toBe(true);
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(titles(snap.data)).toEqual(["Early"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- teardown ------------------------------------------------------------
|
||||
|
||||
it("(k) the last unsubscribe closes the shape query", async () => {
|
||||
|
||||
Reference in New Issue
Block a user