fix!: la reprise cesse de dependre d-un front, et une ouverture abandonnee se termine
Quatrieme tour. Ce n'est pas un drapeau de plus : c'est la forme qui change. Les tours 2 et 3 construisaient tous deux la reprise ainsi - noter qu'on a refuse, puis reagir a un evenement ulterieur - et c'est la note qui etait fautive les deux fois : une fois conditionnee a quelque chose qui ne pouvait pas devenir vrai, une fois ecrite apres la fenetre ou arrive l'evenement decisif. Un front manque n'est pas rattrapable par construction. Donc : - LA DECISION EST UN NIVEAU. Les documents nommes sont reinterroges a chaque publication, jamais reportes depuis la barriere. Une capacite qui arrive change `isError` en document par le canal qui existait deja, et rien n'etant memorise, il n'y a aucun evenement a manquer. C'est aussi ce qui rend sans objet la question du porteur : une requete ouverte pendant qu'un autre etait connecte ne peut pas revenir a cet abonne en succes auquel il n'a pas droit. - UNE TENTATIVE QUI A COURU CONTRE UN CHANGEMENT S'EN APERCOIT au lieu d'attendre. `open()` echantillonne `CapRegistry.version()` avant la barriere et compare apres son echec ; un ecart signifie qu'elle a decide sur un terrain perime, et elle retente d'elle-meme. Cela termine : une tentative ne se repete que parce que le trousseau a bouge, les mouvements sont finis, et ceux qu'une tentative provoque elle-meme sont les capacites qu'elle depose - au plus une par document nomme. La comparaison de porteur disparait : `holderKey()` vaut "" tant que personne n'est connecte, donc un refus pris avant que l'identite se fixe - la ou les composants se montent - ne correspondait plus jamais ensuite. Un instantane d'echec inchange n'est plus republie : la surface documente sa stabilite, et huit arrivees sans rapport produisaient huit objets porteurs de huit `Error` distinctes pour un etat identique. Enfin, un defaut a moi, trouve en mesurant : le changement rendait la suite intermittente. `resetOpenedRepos()` abandonnait les ouvertures en vol sans les terminer, donc un appelant attendait les 8s du repli sur un abonnement demonte, puis repartait avec son etat de synchro efface - et « unknown » est la lecture LAXE, celle qui accepte qu'une lecture a zero ligne soit une absence averee. Une absence-qui-cache-un-echec latente, de la famille meme de cette tache. Une reinitialisation termine desormais ce qu'elle abandonne, en « timed-out ». Deux fixtures qui se couraient apres sont corrigees, raison ecrite a cote.
This commit is contained in:
@@ -376,7 +376,29 @@ export class CapRegistry {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times this registry has CHANGED - a monotonic counter, bumped wherever
|
||||
* {@link notify} is.
|
||||
*
|
||||
* It exists because a listener is an EDGE and some readers need the LEVEL. A reader
|
||||
* that starts an asynchronous piece of work whose outcome depends on what is held can
|
||||
* sample this before and compare after: a change that landed while the work was in
|
||||
* flight fired its listener when there was nothing yet to act on, so the edge is gone
|
||||
* and only this comparison can recover it. `surface/use-shape-query.ts` is that reader,
|
||||
* and it was stuck exactly there through two rounds of review.
|
||||
*
|
||||
* Cheap, and deliberately not a timestamp: what a caller needs is "did anything move
|
||||
* between these two points", never when or what.
|
||||
*/
|
||||
version(): number {
|
||||
return this.changes;
|
||||
}
|
||||
|
||||
/** Bumped on every {@link notify} - see {@link version}. */
|
||||
private changes = 0;
|
||||
|
||||
private notify(): void {
|
||||
this.changes += 1;
|
||||
for (const l of this.listeners) {
|
||||
try {
|
||||
l();
|
||||
|
||||
@@ -93,6 +93,14 @@ export type SyncState = "syncing" | "synced" | "timed-out";
|
||||
const opened = new Set<Nuri>();
|
||||
/** In-flight opens, so concurrent `ensureRepoOpen(nuri)` share one subscription. */
|
||||
const inFlight = new Map<Nuri, Promise<void>>();
|
||||
/**
|
||||
* How to END an in-flight open from outside it - see {@link resetOpenedRepos}, the only
|
||||
* caller. An open normally settles on the first `State` or on its own bounded fallback,
|
||||
* and both of those live inside the subscription; a reset tears that subscription down,
|
||||
* so without this the open is left with nothing that can ever finish it but a timer whose
|
||||
* whole purpose has gone.
|
||||
*/
|
||||
const settleInFlight = new Map<Nuri, () => void>();
|
||||
/** Live bootstrap subscriptions, kept for the session (this is what holds repos open). */
|
||||
const held = new Map<Nuri, Unsubscribe>();
|
||||
/** Explicit per-nuri sync state — the barrier signal, distinct from `opened`.
|
||||
@@ -132,10 +140,26 @@ export function resetOpenedRepos(): void {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// END the opens this reset abandons, BEFORE the maps go: a caller is already awaiting
|
||||
// one, and the `State` it was waiting for cannot arrive now that the subscription above
|
||||
// has been torn down. It used to wait out the full fallback - eight seconds of a read
|
||||
// going nowhere - and then proceed with its sync state CLEARED, which reads as
|
||||
// `"unknown"`; and `unknown` is the LAX answer (`emulated-verifier/index-deposit.ts`
|
||||
// `noRowsMeansAbsent` accepts it), so a zero-row read taken right after a session change
|
||||
// could be called a definitive absence. Marked `"timed-out"` instead, which is the exact
|
||||
// truth - opened best-effort, sync unconfirmed - and the reading that refuses to call
|
||||
// nothing an absence. The abandoned open's own fallback fires later and sets the same
|
||||
// value, so the two cannot disagree.
|
||||
const abandoned = [...settleInFlight.entries()];
|
||||
opened.clear();
|
||||
inFlight.clear();
|
||||
settleInFlight.clear();
|
||||
held.clear();
|
||||
syncState.clear();
|
||||
for (const [nuri, settle] of abandoned) {
|
||||
syncState.set(nuri, "timed-out");
|
||||
settle();
|
||||
}
|
||||
boundSessionId = null;
|
||||
OPEN_TIMEOUT_MS = 8000;
|
||||
// Whatever ELSE was still subscribed to a document - an inbox observation, an
|
||||
@@ -259,6 +283,13 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
|
||||
const p = (async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
// Handed to `resetOpenedRepos`, so an open this session abandons can be ended
|
||||
// rather than left hanging on a subscription that no longer exists.
|
||||
settleInFlight.set(nuri, () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
});
|
||||
// Resolve on the first `State` (the sync BARRIER), marking the nuri "synced".
|
||||
const onState = (): void => {
|
||||
if (settled) return;
|
||||
@@ -292,6 +323,7 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
|
||||
});
|
||||
opened.add(nuri);
|
||||
inFlight.delete(nuri);
|
||||
settleInFlight.delete(nuri);
|
||||
})();
|
||||
inFlight.set(nuri, p);
|
||||
return p;
|
||||
|
||||
@@ -118,7 +118,27 @@
|
||||
* 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-ATTEMPTS a refused scope, for the holder that was refused.
|
||||
* So the signal re-ATTEMPTS a refused scope. Two things make that sound, and both were
|
||||
* arrived at by getting them wrong first:
|
||||
*
|
||||
* - **the decision is a LEVEL, not a memory.** Which documents the holder may reach is
|
||||
* re-read on every publication ({@link publish}), never carried over from the barrier.
|
||||
* So a cap arriving turns a refusal into the document through the channel that
|
||||
* already existed, and a query opened while somebody else was connected cannot come
|
||||
* back to this subscriber as a success it is not entitled to. Nothing is remembered,
|
||||
* so there is no event to miss.
|
||||
* - **an attempt that RACED a change notices, rather than waiting.** A keyring change
|
||||
* landing while the barrier is crossing fires its listener when there is no query to
|
||||
* re-publish and no refusal on record - and that same change is often what makes the
|
||||
* walk refuse. The edge is simply gone. So the attempt samples `CapRegistry.version()`
|
||||
* before it starts and compares after it fails; a mismatch means it decided on stale
|
||||
* ground and it makes another attempt itself.
|
||||
*
|
||||
* This is the third shape this recovery has had, and the first two failed the same way -
|
||||
* each recorded that it had refused and then waited for a later event. Round 2 gated the
|
||||
* record on a condition that could not become true; round 3 set the record after the
|
||||
* window in which the deciding event arrives. An edge that is missed is not recoverable
|
||||
* by construction, which is why the answer here is to stop depending on edges.
|
||||
*
|
||||
* -- Why there is no cleverer gate than "attempt again" ----------------------------
|
||||
* The obvious one is to re-open only once the refusal has been lifted - "does the holder
|
||||
@@ -240,6 +260,19 @@ function namedDocuments(scope: Scope): Nuri[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Are these two failures the same one, for the purpose of deciding whether a subscriber
|
||||
* has anything new to look at?
|
||||
*
|
||||
* By MESSAGE, because every attempt builds its own `Error` object and identity would
|
||||
* therefore always answer "different". A message is what an application shows and what a
|
||||
* maintainer greps for, so two failures that read alike are alike here.
|
||||
*/
|
||||
function sameFailure(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
return a instanceof Error && b instanceof Error && a.message === b.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
|
||||
* `scope` - the SDK's own `{ graphs, subjects }`, passed through untouched. See the
|
||||
@@ -280,24 +313,24 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
* a change signal that beats `readyPromise` must not be read as an answer. */
|
||||
let ready = false;
|
||||
/**
|
||||
* WHOSE refusal is standing, or `null` when the barrier is not what failed.
|
||||
* Is the BARRIER what left this observable without a query - rather than the shape
|
||||
* query failing to open, or nothing having been tried yet?
|
||||
*
|
||||
* It carries two things the keyring signal needs, and the second is what a first
|
||||
* attempt at this got wrong.
|
||||
* Only the first is worth attempting again: a `configure` with no `ormSubscription` is
|
||||
* not repaired by a cap arriving, and retrying it on every keyring change would churn
|
||||
* for a misconfiguration no amount of retrying repairs.
|
||||
*
|
||||
* - **That the BARRIER is what failed**, rather than the shape query failing to open
|
||||
* or nothing having been tried yet. A `configure` with no `ormSubscription` is not
|
||||
* going to be repaired by a cap arriving, so retrying it on every keyring change
|
||||
* would churn for a misconfiguration no amount of retrying repairs.
|
||||
* - **For WHICH holder.** A re-open is not a re-publication: it issues a subscribe
|
||||
* and a public-store fetch for a document THIS subscriber named. Deciding to make
|
||||
* that request from whatever identity happens to be connected when an unrelated cap
|
||||
* lands would perform an access on behalf of somebody who never asked for it - and
|
||||
* leave the original subscriber reading a query opened in another name, which comes
|
||||
* back as `isSuccess` holding nothing once its own filter runs. That is the exact
|
||||
* lie this barrier exists to remove, arriving through the repair's own door.
|
||||
* It records no HOLDER, and an earlier version did. Keying the retry on "the same
|
||||
* holder that was refused" looks like the way to stop a re-attempt acting for somebody
|
||||
* who never asked, and it is a trap twice over: the anonymous ring is a holder like any
|
||||
* other, so a refusal taken before the identity settles - which is where components
|
||||
* mount - never matched again once it did; and an identity CAN change under a live page
|
||||
* (`shared-wallet/access-gate.ts` adopts the identifier a revived barrier confirms), so
|
||||
* the rule left that case terminal too. What actually has to hold is that no snapshot
|
||||
* ever claims success for a document the CURRENT holder cannot reach, and that is
|
||||
* enforced where it belongs, on every publication - see {@link publish}.
|
||||
*/
|
||||
let refusedFor: string | null = null;
|
||||
let barrierRefused = false;
|
||||
|
||||
function emit(): void {
|
||||
for (const l of listeners) {
|
||||
@@ -328,6 +361,16 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
* initial value, published under `isError`, never under `isSuccess`.
|
||||
*/
|
||||
function setUnknown(error: unknown): void {
|
||||
// A standing failure that has not changed is not news. This surface documents its
|
||||
// snapshot as STABLE until it actually changes, and a re-attempt that lands on the
|
||||
// same refusal changes nothing an application renders - so handing out a fresh object
|
||||
// carrying a fresh `Error` would break that promise and re-render every bound view
|
||||
// for nothing. Measured: eight unrelated cap arrivals over a failing fetch produced
|
||||
// eight notifications and eight distinct `Error`s for one unchanged state.
|
||||
//
|
||||
// Compared by MESSAGE rather than by identity, because each attempt builds its own
|
||||
// `Error`. A failure that says something different IS news and is published.
|
||||
if (snapshot.isError && sameFailure(snapshot.error, error)) return;
|
||||
setSnapshot({
|
||||
data: snapshot.data,
|
||||
isPending: false,
|
||||
@@ -355,10 +398,49 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
return kept as T[];
|
||||
}
|
||||
|
||||
/** Publish what the subscription holds right now. Only ever called on a READY
|
||||
* generation, so this snapshot is always an answer. */
|
||||
/**
|
||||
* The refusal standing against the CURRENT holder for the first document the scope
|
||||
* NAMES that it cannot reach, or `null` when it reaches all of them.
|
||||
*
|
||||
* It goes through `assertMayReach` and catches, rather than deciding again: the refusal
|
||||
* is worded in exactly one place and a second wording here would drift from it. Scope
|
||||
* order, like the barrier.
|
||||
*/
|
||||
function namedRefusal(): unknown {
|
||||
for (const doc of namedDocuments(scope)) {
|
||||
try {
|
||||
assertMayReach(doc, "useShapeQuery");
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish what the subscription holds right now. Only ever called on a READY
|
||||
* generation, so this snapshot is always an answer.
|
||||
*
|
||||
* **The named documents are re-checked HERE, every time, and that is what makes the
|
||||
* refusal self-healing.** The barrier decides once, before the query opens; this
|
||||
* decides on each publication, against what the holder holds at that instant - so a cap
|
||||
* arriving turns `isError` into the document through the channel that already existed,
|
||||
* and a query opened while somebody else was connected can never come back to this
|
||||
* subscriber as `isSuccess` holding nothing. A level, not an edge: there is no event to
|
||||
* miss, because nothing is remembered.
|
||||
*
|
||||
* The capability FILTER below still applies on top, and the two are not the same
|
||||
* question: the filter bounds what a scope may show per `@graph`, this refuses a scope
|
||||
* the caller NAMED and cannot read. Under `did:ng:i` nothing is named and the filter is
|
||||
* the whole of the answer, exactly as before.
|
||||
*/
|
||||
function publish(handle: OrmSubscriptionHandle, gen: number): void {
|
||||
if (gen !== generation || !ready) return;
|
||||
const refusal = namedRefusal();
|
||||
if (refusal !== null) {
|
||||
setUnknown(refusal);
|
||||
return;
|
||||
}
|
||||
setSnapshot({
|
||||
data: readable(handle),
|
||||
isPending: false,
|
||||
@@ -461,13 +543,31 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
function open(): void {
|
||||
closeQuery();
|
||||
const gen = ++generation;
|
||||
refusedFor = null;
|
||||
barrierRefused = false;
|
||||
// Sampled SYNCHRONOUSLY, before anything is awaited: what the keyring looked like when
|
||||
// this attempt decided to trust it. See the comparison in the catch below.
|
||||
const keyringAtStart = getCaps().version();
|
||||
void (async () => {
|
||||
try {
|
||||
await reachNamedDocuments();
|
||||
} catch (error) {
|
||||
if (gen !== generation) return;
|
||||
refusedFor = getCaps().holderKey();
|
||||
// THE EDGE THAT WOULD OTHERWISE BE MISSED, and it is the one that matters most: a
|
||||
// keyring change landing WHILE the barrier was crossing fired its listener at a
|
||||
// moment when there was no query to re-publish and no refusal on record yet, so
|
||||
// the listener did nothing - and that same change is what made the walk refuse.
|
||||
// Waiting for another event would be waiting for one that may never come; this
|
||||
// attempt simply notices it raced and makes another, which re-samples.
|
||||
//
|
||||
// It terminates: an attempt is repeated only because the keyring moved, moves are
|
||||
// finite events, and the moves an attempt causes itself are the caps it files -
|
||||
// at most one per named document, after which that document is held and asks
|
||||
// nothing.
|
||||
if (getCaps().version() !== keyringAtStart) {
|
||||
open();
|
||||
return;
|
||||
}
|
||||
barrierRefused = true;
|
||||
setUnknown(error);
|
||||
return;
|
||||
}
|
||||
@@ -522,10 +622,11 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
// 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 ATTEMPT AGAIN, for the holder that was refused. There is deliberately no
|
||||
// further test of whether the refusal has been lifted: see the note below on why
|
||||
// the obvious one is circular, and why an attempt is the cheaper question anyway.
|
||||
if (refusedFor !== null && refusedFor === getCaps().holderKey()) open();
|
||||
// So ATTEMPT AGAIN. There is deliberately no further test of whether the refusal
|
||||
// has been lifted, and none of who is asking: see the module header for why the
|
||||
// obvious gate is circular, why an attempt is the cheaper question anyway, and why
|
||||
// the holder belongs in {@link publish} rather than here.
|
||||
if (barrierRefused) open();
|
||||
});
|
||||
open();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { ensureRepoOpen, resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
||||
import { ensureRepoOpen, getSyncState, resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
||||
import { readDocumentSubjects } from "../src/surface/read-document";
|
||||
import { configure } from "../src/index";
|
||||
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||
@@ -55,7 +55,11 @@ const FP = "http://festipod.org/";
|
||||
|
||||
function makeFakeNgWithSubscribe(
|
||||
triplesByDoc: Record<string, Array<[string, string]>>,
|
||||
options: { pushState?: boolean } = {},
|
||||
) {
|
||||
// A broker that accepts the subscription and never pushes is the state the bounded
|
||||
// fallback exists for, and the only way to hold an open genuinely IN FLIGHT.
|
||||
const pushState = options.pushState !== false;
|
||||
const subscribed = new Set<string>();
|
||||
const subscribeCallOrder: string[] = [];
|
||||
|
||||
@@ -65,7 +69,7 @@ function makeFakeNgWithSubscribe(
|
||||
subscribeCallOrder.push(nuri);
|
||||
// Simulate initial State push (synchronously deferred so the subscription
|
||||
// setup promise path in ensureRepoOpen can resolve it).
|
||||
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||
if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||
return () => {}; // unsubscribe fn
|
||||
});
|
||||
|
||||
@@ -168,6 +172,38 @@ describe("ensureRepoOpen", () => {
|
||||
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// An open ENDS, whatever happens to the registry underneath it.
|
||||
//
|
||||
// A reset - which is what a session change does, `syncSession` - tears down the
|
||||
// subscription an in-flight open is waiting on. Until 2026-09-21 it then left that open
|
||||
// hanging: its `State` could no longer arrive, so the caller waited out the whole
|
||||
// 8-second fallback and only then proceeded, with its sync state CLEARED. Cleared reads
|
||||
// as `"unknown"`, and `unknown` is the LAX answer - `emulated-verifier/index-deposit.ts`
|
||||
// accepts it as "a zero-row read means the document is empty" - so a read taken right
|
||||
// after a session change could have an absence read into it. Both halves are the point:
|
||||
// it must END, and it must end as `"timed-out"`.
|
||||
it("a reset ENDS the opens it abandons, and marks them timed-out rather than unknown", async () => {
|
||||
const ng = makeFakeNgWithSubscribe({}, { pushState: false });
|
||||
inject(ng);
|
||||
|
||||
const inFlight = ensureRepoOpen("did:ng:o:abandoned");
|
||||
await new Promise((r) => setTimeout(r, 5)); // let the subscribe land
|
||||
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
|
||||
|
||||
resetOpenedRepos();
|
||||
|
||||
// Ends at once. Without the fix it resolves only when the 8s fallback fires, which is
|
||||
// longer than any test's patience and longer than any page's.
|
||||
await expect(
|
||||
Promise.race([
|
||||
inFlight.then(() => "ended"),
|
||||
new Promise((r) => setTimeout(() => r("still waiting"), 200)),
|
||||
]),
|
||||
).resolves.toBe("ended");
|
||||
// ...and NOT as a barrier that was reached.
|
||||
expect(getSyncState("did:ng:o:abandoned")).toBe("timed-out");
|
||||
});
|
||||
|
||||
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
|
||||
const ng = makeFakeNgWithSubscribe({
|
||||
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
|
||||
|
||||
@@ -72,7 +72,14 @@ function makeFake(options: { pushState?: boolean } = {}) {
|
||||
let docCounter = 0;
|
||||
const subs: SubRec[] = [];
|
||||
|
||||
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||
// A NAMESPACE OF ITS OWN, and it is not cosmetic. `wallet-fake.ts` mints into
|
||||
// `did:ng:o:doc<n>` too, and the bootstrap open registry (`emulated-verifier/open-repo.ts`)
|
||||
// is process-global - so the two fakes aliased each other's repos across test files, and
|
||||
// an in-flight open abandoned here could be awaited there against a broker that no longer
|
||||
// answers, hanging it until the 8s fallback. `wallet-fake` carries the same lesson at its
|
||||
// own scale ("a broker never mints a repo id twice - an id is a public key - so neither
|
||||
// does this"); this is that lesson one scope wider.
|
||||
const doc_create = mock(async () => `did:ng:o:usq${++docCounter}`);
|
||||
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
@@ -713,52 +720,49 @@ describe("useShapeQuery", () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(s) a re-attempt that cannot succeed spends NO round-trip: an absence is memoised", async () => {
|
||||
it("(s) a re-attempt re-asks the network only for the class a re-ask can repair", 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");
|
||||
// Two public documents, and what separates them is the BROKER, not the library: one
|
||||
// answers (and, being alice's, exposes a cap bob will not be given), the other cannot
|
||||
// be asked at all. The first is an ANSWER and is memoised; the second is a failure
|
||||
// and deliberately is not (`public-store.ts`).
|
||||
const answers = await createEntityDoc("alice", "protected");
|
||||
const cannotBeAsked = await createEntityDoc("alice", "public");
|
||||
|
||||
setCurrentUser("bob");
|
||||
const obs = useShapeQuery(EventShape, { graphs: [note] });
|
||||
let notes = 0;
|
||||
const unsub = obs.subscribe(() => {
|
||||
notes += 1;
|
||||
});
|
||||
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
ng._unreachable.add(cannotBeAsked);
|
||||
const asks = (doc: string): number =>
|
||||
ng.sparql_query.mock.calls.filter((c) => c[3] === doc).length;
|
||||
|
||||
/** 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;
|
||||
const memoised = useShapeQuery(EventShape, { graphs: [answers] });
|
||||
const unsubA = memoised.subscribe(() => {});
|
||||
const outage = useShapeQuery(EventShape, { graphs: [cannotBeAsked] });
|
||||
const unsubB = outage.subscribe(() => {});
|
||||
for (let i = 0; i < 20 && !(memoised.getSnapshot().isError && outage.getSnapshot().isError); i++) {
|
||||
await tick();
|
||||
}
|
||||
expect(memoised.getSnapshot().isError).toBe(true);
|
||||
expect(outage.getSnapshot().isError).toBe(true);
|
||||
const answeredAt = asks(answers);
|
||||
const outageAt = asks(cannotBeAsked);
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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)).
|
||||
// Three unrelated arrivals. Both scopes re-attempt on each - there is deliberately no
|
||||
// predicate deciding which is worth it, because every predicate tried so far left a
|
||||
// class permanently stuck. The MEMO is what makes that affordable, and the two
|
||||
// counts below are the proof, each the other's guard against vacuity: a re-ask that
|
||||
// cannot repair anything costs nothing, and the one that can is actually made.
|
||||
for (let round = 0; round < 3; round++) {
|
||||
getCaps().learn(mintCap(`did:ng:o:unrelated-${round}`));
|
||||
for (let i = 0; i < 5; i++) await tick();
|
||||
for (let i = 0; i < 6; 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(asks()).toBe(afterRefusal);
|
||||
unsub();
|
||||
expect(asks(answers)).toBe(answeredAt); // an absence was read, and is remembered
|
||||
expect(asks(cannotBeAsked)).toBeGreaterThan(outageAt); // a failure is asked again
|
||||
unsubA();
|
||||
unsubB();
|
||||
});
|
||||
|
||||
it("(t) named documents cross the barrier TOGETHER, so N that never sync cost one wait, not N", async () => {
|
||||
@@ -799,7 +803,13 @@ describe("useShapeQuery", () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("rex");
|
||||
// NOBODY is signed in, and that is what keeps the registry inert for the whole test.
|
||||
// Signing in provisions an account and provisioning MINTS, so `setCurrentUser` arms
|
||||
// the emulation a tick or two later: a fixture that asserts `isEnforcing() === false`
|
||||
// and then subscribes is racing itself, and it won that race only until the refusal
|
||||
// began being re-read on every publication - at which point a scope named by a holder
|
||||
// of nothing started answering, correctly, `isError`. The inert case is worth pinning;
|
||||
// a fixture that stops being inert halfway through pins the schedule instead.
|
||||
const early = "did:ng:o:inert-named";
|
||||
seedEvent(orm, early, `${early}:q:one`, "Early");
|
||||
expect(getCaps().isEnforcing()).toBe(false);
|
||||
@@ -858,7 +868,7 @@ describe("useShapeQuery", () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(w) a refused scope never re-opens under a DIFFERENT identity", async () => {
|
||||
it("(w) no snapshot claims success for a document the CURRENT holder cannot reach", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
@@ -869,26 +879,44 @@ describe("useShapeQuery", () => {
|
||||
setCurrentUser("bob");
|
||||
const obs = useShapeQuery(EventShape, { graphs: [hers] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
||||
for (let i = 0; i < 20 && !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.
|
||||
// The page becomes Alice, which a live page CAN do: `shared-wallet/access-gate.ts`
|
||||
// adopts the identifier a revived barrier confirms. The re-attempt is deliberately
|
||||
// not keyed on the holder - that rule left a refusal taken before the identity
|
||||
// settled, and one taken before an identity change, both permanently stuck - so a
|
||||
// query may well be opened while she is the one connected.
|
||||
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
|
||||
for (let i = 0; i < 12; i++) await tick();
|
||||
|
||||
// ...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.
|
||||
// ...and back under Bob the answer has to be the refusal. What guarantees it is that
|
||||
// the named documents are re-checked on every publication rather than remembered
|
||||
// from the barrier: whoever opened the query, this subscriber is told what IT may
|
||||
// read. `isSuccess` holding nothing here would be the exact lie this task removes,
|
||||
// arriving through 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);
|
||||
for (let i = 0; i < 12; i++) await tick();
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(snap.isError).toBe(true);
|
||||
// `data` is deliberately NOT asserted empty here. Under `isError` this surface keeps
|
||||
// the last read that ANSWERED - an observable cannot un-emit - and what answered was
|
||||
// the publication made while Alice was connected. That an observable follows whoever
|
||||
// is connected rather than whoever subscribed is older than this work and untouched
|
||||
// by it: at `5cbcabe`, before any of this, `readable()` already consulted the current
|
||||
// holder and `caps.onChange` already re-published through it. Reported as a
|
||||
// follow-up, not repaired here. What this test pins is the STATE, which is what an
|
||||
// application branches on and what the refusal is about.
|
||||
|
||||
// And Alice, connected again, is not refused her own document - the check is about
|
||||
// the holder of the moment, not a grudge held from the first attempt.
|
||||
setCurrentUser("alice");
|
||||
getCaps().learn(mintCap("did:ng:o:unrelated-arrival-3"));
|
||||
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["Hers"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
@@ -916,6 +944,111 @@ describe("useShapeQuery", () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- the edge that is MISSED, which is the shape of the whole problem -----
|
||||
//
|
||||
// Recovery had been built twice as "record that we refused, then react to a later
|
||||
// event". Both times the record was the fault: once gated on a condition that could
|
||||
// not become true, once set too late to catch the event that caused the refusal. An
|
||||
// edge that is missed is not recoverable by construction, so these pin the races
|
||||
// rather than the flags.
|
||||
|
||||
it("(y) a refusal caused BY the last keyring event still converges - the missed edge", 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");
|
||||
|
||||
// A fresh page over the same broker: the document and its exposed cap are on the
|
||||
// wire, this registry holds nothing, and nothing is enforcing yet.
|
||||
setCurrentUser("bob");
|
||||
resetCaps();
|
||||
expect(getCaps().isEnforcing()).toBe(false);
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: [pub] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
// The barrier is IN FLIGHT: inert, so it skipped the public-store ask entirely and is
|
||||
// waiting on the sync barrier. The connection burst files this holder's first cap
|
||||
// right now, which flips the registry to enforcing - and it is that very event that
|
||||
// makes the walk refuse a moment later. There is no keyring event after it.
|
||||
getCaps().mint("did:ng:o:the-first-cap-of-the-session");
|
||||
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
|
||||
|
||||
// `refetch()` would answer at once, so the error was for nothing: the state was
|
||||
// recoverable the whole time and only the RECOVERY was missing.
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(titles(snap.data)).toEqual(["Public"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(z) a refusal taken while nobody is connected converges once the identity settles", 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");
|
||||
|
||||
// Refused with nobody connected - the state a page is in before its identity has
|
||||
// settled, which is where components mount. The holder is the anonymous ring.
|
||||
setCurrentUser(null);
|
||||
const obs = useShapeQuery(EventShape, { graphs: [hers] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
for (let i = 0; i < 20 && !obs.getSnapshot().isError; i++) await tick();
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
|
||||
// ...and the identity settles, holding the cap. This is the ORDINARY sign-in path, not
|
||||
// an identity swap: `access-gate` adopts the settled identifier and the connection
|
||||
// work files what this holder holds. A recovery keyed on "the same holder as the one
|
||||
// refused" never fires here, because the refusal was recorded for nobody.
|
||||
setCurrentUser("alice");
|
||||
getCaps().learn(mintCap(hers));
|
||||
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(titles(snap.data)).toEqual(["Hers"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(aa) a standing refusal that has not changed does not re-publish a new snapshot", 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");
|
||||
ng._unreachable.add(pub); // the fetch FAILS, which is the class that is re-asked
|
||||
const obs = useShapeQuery(EventShape, { graphs: [pub] });
|
||||
let notes = 0;
|
||||
const unsub = obs.subscribe(() => {
|
||||
notes += 1;
|
||||
});
|
||||
for (let i = 0; i < 20 && !obs.getSnapshot().isError; i++) await tick();
|
||||
const refusedSnapshot = obs.getSnapshot();
|
||||
const notesAfterRefusal = notes;
|
||||
|
||||
// Eight unrelated arrivals, the broker still down. Each re-attempt genuinely re-asks
|
||||
// - that is the repair for this class and it is not a cost to avoid - but the ANSWER
|
||||
// has not moved, and this surface documents its snapshot as stable until it actually
|
||||
// changes. Handing subscribers eight fresh objects carrying eight fresh `Error`s for
|
||||
// an unchanged state breaks that, and makes every bound view re-render for nothing.
|
||||
for (let round = 0; round < 8; round++) {
|
||||
getCaps().learn(mintCap(`did:ng:o:unrelated-${round}`));
|
||||
for (let i = 0; i < 4; i++) await tick();
|
||||
}
|
||||
|
||||
expect(obs.getSnapshot().isError).toBe(true);
|
||||
expect(obs.getSnapshot()).toBe(refusedSnapshot); // the SAME object, not an equal one
|
||||
expect(notes).toBe(notesAfterRefusal);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- teardown ------------------------------------------------------------
|
||||
|
||||
it("(k) the last unsubscribe closes the shape query", async () => {
|
||||
|
||||
Reference in New Issue
Block a user