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:
@@ -195,7 +195,9 @@ Where a call must first find out whether something already exists — a document
|
|||||||
|
|
||||||
**A document you NAME in `graphs` is reached before the read opens, since `1.0.0-dev.9`.** Naming a document in a scope is the same act as anchoring `docs.sparqlQuery` on it, and it is now answered the same way: the key is asked of a public store if you do not already hold it, the document is refused out loud if you hold no key for it, and its repo is synced before the question is put. A named document you cannot read publishes `isError`, where until this version it reached the engine, came back, and was emptied by the per-document filter into `isSuccess` with `data: []` - the one snapshot this surface reserves for "this scope holds nothing". A named document that has never synced published that same snapshot for a document that is not empty; it too is now `isError` or a longer `isPending`, never a false emptiness. **Naming SEVERAL documents fails as a whole** when one of them cannot be reached: a short list is the same lie as an empty one, so nothing is answered rather than half of it. What is NOT touched, and each stays a success: `graphs: "did:ng:i"` or `""` (you named no document - the per-document filter is the whole of the answer there, and an empty result means you hold nothing of that shape), `graphs: []` (no objects, by NextGraph's own definition), and a named document you can read that holds nothing of the shape.
|
**A document you NAME in `graphs` is reached before the read opens, since `1.0.0-dev.9`.** Naming a document in a scope is the same act as anchoring `docs.sparqlQuery` on it, and it is now answered the same way: the key is asked of a public store if you do not already hold it, the document is refused out loud if you hold no key for it, and its repo is synced before the question is put. A named document you cannot read publishes `isError`, where until this version it reached the engine, came back, and was emptied by the per-document filter into `isSuccess` with `data: []` - the one snapshot this surface reserves for "this scope holds nothing". A named document that has never synced published that same snapshot for a document that is not empty; it too is now `isError` or a longer `isPending`, never a false emptiness. **Naming SEVERAL documents fails as a whole** when one of them cannot be reached: a short list is the same lie as an empty one, so nothing is answered rather than half of it. What is NOT touched, and each stays a success: `graphs: "did:ng:i"` or `""` (you named no document - the per-document filter is the whole of the answer there, and an empty result means you hold nothing of that shape), `graphs: []` (no objects, by NextGraph's own definition), and a named document you can read that holds nothing of the shape.
|
||||||
|
|
||||||
**And it converges by itself.** A named scope refused this way is not a dead view: the keyring signal that already re-published a filtered read now also re-ATTEMPTS a scope the barrier refused. So the ordinary sequence - a page whose components subscribe while the connection work is still restoring keys, a document shared with you while you are looking at the screen, or a broker that hiccupped once and healed - shows `isError` and then the document, with no call from the application. Both ways of being refused are covered, and they are repaired by opposite things: one needs the key to arrive from elsewhere, the other needs the store to be asked again, and every keyring movement re-attempts both. One consequence to design for: an interface that renders `isError` will briefly show it where it used to show an empty success, so branch on the load state rather than on `data` alone. Nothing here polls, so a scope refused in a session whose keyring never moves again stays `isError` until your own `refetch()` - which is what `isError` has always meant here, and what it means for every other failure this call reports.
|
**And it converges by itself.** A named scope refused this way is not a dead view. Whether you may reach the documents you named is re-read on every publication rather than decided once, so a key arriving turns the refusal into the document through the same channel that already re-published a filtered read - and a scope whose barrier failed outright is attempted again on that signal. The ordinary sequences all converge with no call from the application: a page whose components subscribe before the connection work has restored its keys, a document shared with you while you are looking at the screen, a broker that hiccupped once and healed, and a key arriving in the same instant the read was deciding. One consequence to design for: an interface that renders `isError` will briefly show it where it used to show an empty success, so branch on the load state rather than on `data` alone. Nothing here polls, so a scope refused in a session whose keyring never moves again stays `isError` until your own `refetch()` - which is what `isError` has always meant here, and what it means for every other failure this call reports.
|
||||||
|
|
||||||
|
**A standing failure is the SAME snapshot.** While a refusal persists unchanged, the snapshot object does not change either, so `useSyncExternalStore` and anything else comparing by identity see no update - even though the read is being attempted again underneath. A failure that says something different is published normally.
|
||||||
|
|
||||||
**What it costs.** One public-store ask and one sync barrier per document named, once per session. The opens for a scope naming several documents run together, so the wait is the slowest of them rather than their sum. A document that never answers the barrier is bounded by an internal fallback rather than waiting for ever, and until `1.0.0-dev.9` that same document left the read `isPending` with no bound at all.
|
**What it costs.** One public-store ask and one sync barrier per document named, once per session. The opens for a scope naming several documents run together, so the wait is the slowest of them rather than their sum. A document that never answers the barrier is bounded by an internal fallback rather than waiting for ever, and until `1.0.0-dev.9` that same document left the read `isPending` with no bound at all.
|
||||||
|
|
||||||
@@ -277,7 +279,7 @@ What each level means here, in this package's own terms:
|
|||||||
|
|
||||||
- **`useShapeQuery` publishes `isError` for a document you NAME and cannot read.** Until this version a document named in `scope.graphs` was handed straight to the engine; whatever came back was filtered per document by what you hold, and a document you hold no key for was emptied out of the result. The snapshot was `isSuccess` with `data: []` - indistinguishable, byte for byte, from the scope genuinely holding nothing. It now asks the public store for the key, refuses out loud if there is none to be had, and crosses the sync barrier first, so the same read answers `isError`. **Handle `isError` on a named scope**; an interface that only branched on `isPending` / `isSuccess` will show its success state with nothing in it. Naming several documents fails as a whole when one of them fails.
|
- **`useShapeQuery` publishes `isError` for a document you NAME and cannot read.** Until this version a document named in `scope.graphs` was handed straight to the engine; whatever came back was filtered per document by what you hold, and a document you hold no key for was emptied out of the result. The snapshot was `isSuccess` with `data: []` - indistinguishable, byte for byte, from the scope genuinely holding nothing. It now asks the public store for the key, refuses out loud if there is none to be had, and crosses the sync barrier first, so the same read answers `isError`. **Handle `isError` on a named scope**; an interface that only branched on `isPending` / `isSuccess` will show its success state with nothing in it. Naming several documents fails as a whole when one of them fails.
|
||||||
- **A named scope now costs round-trips it did not cost before**, once per named document per session: the key fetch, and the subscribe that syncs the repo. The opens run together, so several named documents cost the slowest wait rather than the sum. `graphs: "did:ng:i"` is unchanged and spends nothing.
|
- **A named scope now costs round-trips it did not cost before**, once per named document per session: the key fetch, and the subscribe that syncs the repo. The opens run together, so several named documents cost the slowest wait rather than the sum. `graphs: "did:ng:i"` is unchanged and spends nothing.
|
||||||
- **A refused named scope RECOVERS on its own**, through the same keyring signal that already re-published a filtered read - whether what it needed was a key arriving from elsewhere or the public store simply being asked again. Without that a refusal would be terminal, and trading a silent empty answer for a permanently stuck error would have been no improvement at all. Expect to see `isError` briefly on a page that subscribes before its keys are restored. There is no polling: in a session whose keyring never moves again, `refetch()` is the recovery, as it is for every other failure this call reports.
|
- **A refused named scope RECOVERS on its own.** Reachability is re-read on every publication rather than remembered, and a scope whose barrier failed is attempted again when the keyring moves - including when the move happened while the read was still deciding. Without that a refusal would be terminal, and trading a silent empty answer for a permanently stuck error would have been no improvement at all. Expect to see `isError` briefly on a page that subscribes before its keys are restored. There is no polling: in a session whose keyring never moves again, `refetch()` is the recovery, as it is for every other failure this call reports.
|
||||||
- **`inbox.postToDocument`, `inbox.readForDocument` and `inbox.readSyncedForDocument` reject where they used to report "no inbox".** Resolving a document's inbox address reads that document, and when the read could not happen - the broker could not answer for it, or its public store could not be asked for the key - all three used to render the breakdown as the document's owner having opened nothing: a refusal on the deposit side, an empty list on the read side. They now reject. A document that genuinely has no inbox is unchanged, and still answers exactly as before.
|
- **`inbox.postToDocument`, `inbox.readForDocument` and `inbox.readSyncedForDocument` reject where they used to report "no inbox".** Resolving a document's inbox address reads that document, and when the read could not happen - the broker could not answer for it, or its public store could not be asked for the key - all three used to render the breakdown as the document's owner having opened nothing: a refusal on the deposit side, an empty list on the read side. They now reject. A document that genuinely has no inbox is unchanged, and still answers exactly as before.
|
||||||
- **Nothing that was already an ANSWER moves.** An empty document is a success holding zero objects, `graphs: []` is a success holding none, a document in a public store that exposes no key is a plain "no", and a document with no inbox answers `[]`. The whole change is that a *failure* stopped borrowing those shapes.
|
- **Nothing that was already an ANSWER moves.** An empty document is a success holding zero objects, `graphs: []` is a success holding none, a document in a public store that exposes no key is a plain "no", and a document with no inbox answers `[]`. The whole change is that a *failure* stopped borrowing those shapes.
|
||||||
- **Why this direction, and not a richer set of outcomes.** The step that collapsed the two was this package's own emulation of a public store serving its key; NextGraph has no counterpart for it, because upstream nothing consults a capability on the read path at all. So there is no signature to mirror, and the alignment falls to the engine's own observable: an anchored query on a repo the verifier does not hold answers `NgError::RepoNotFound`, a rejected promise - **a read rejects, or it does not**. Inventing a third outcome for a caller to branch on would have been a vocabulary the target will never grow.
|
- **Why this direction, and not a richer set of outcomes.** The step that collapsed the two was this package's own emulation of a public store serving its key; NextGraph has no counterpart for it, because upstream nothing consults a capability on the read path at all. So there is no signature to mirror, and the alignment falls to the engine's own observable: an anchored query on a repo the verifier does not hold answers `NgError::RepoNotFound`, a rejected promise - **a read rejects, or it does not**. Inventing a third outcome for a caller to branch on would have been a vocabulary the target will never grow.
|
||||||
|
|||||||
@@ -141,11 +141,11 @@ Fix, round 2: the keyring signal RE-OPENS a scope the barrier refused, gated on
|
|||||||
|
|
||||||
**Finding 1, blocking and STRUCTURAL - the gate was circular.** `namedDocumentsReachable()` tested `mayReach`, i.e. the cap is HELD. For a named document in a PUBLIC store the only channel that can ever file that cap is `fetchReadCap` - the very call that had failed and set the flag. The gate waited on an answer that only the action it gated could produce, so that whole class was unrecoverable however many caps arrived. Reproduced as `use-shape-query.test.ts` (v): the broker hiccups on the `exposedReadCap` read, the fetch rejects, the broker heals, an ordinary cap arrival follows, and the view never re-asks.
|
**Finding 1, blocking and STRUCTURAL - the gate was circular.** `namedDocumentsReachable()` tested `mayReach`, i.e. the cap is HELD. For a named document in a PUBLIC store the only channel that can ever file that cap is `fetchReadCap` - the very call that had failed and set the flag. The gate waited on an answer that only the action it gated could produce, so that whole class was unrecoverable however many caps arrived. Reproduced as `use-shape-query.test.ts` (v): the broker hiccups on the `exposedReadCap` read, the fetch rejects, the broker heals, an ordinary cap arrival follows, and the view never re-asks.
|
||||||
|
|
||||||
**The readiness question, answered rather than patched.** There are exactly two ways this barrier can refuse, and the memo already tells them apart. The store could not be ASKED: a failure is deliberately not memoised (an earlier fix in this repo), so a re-attempt genuinely re-asks the network - which IS the repair for this class, not a cost to avoid. Or the store ANSWERED and there is no cap to be had: an absence is memoised, so a re-attempt asks nobody, re-runs the barrier against what is already known, and republishes the same refusal for the price of one snapshot - and it stops the day the cap arrives from elsewhere, which is the only thing that repairs THAT class. So the right signal is not a cleverer predicate, it is **attempting again**: cheap for the class it cannot help, correct for the class it can, self-limiting because the moment an attempt succeeds there is a query again. `namedDocumentsReachable()` is DELETED - the repair makes the module smaller than round 2 left it. Pinned by (v) and, for the cost, by (s), which now asserts both halves (the re-attempt ran, and it spent no round-trip) so it cannot pass vacuously.
|
**The readiness question, answered rather than patched.** There are exactly two ways this barrier can refuse, and the memo already tells them apart. The store could not be ASKED: a failure is deliberately not memoised (an earlier fix in this repo), so a re-attempt genuinely re-asks the network - which IS the repair for this class, not a cost to avoid. Or the store ANSWERED and there is no cap to be had: an absence is memoised, so a re-attempt asks nobody, re-runs the barrier against what is already known, and republishes the same refusal for the price of one snapshot - and it stops the day the cap arrives from elsewhere, which is the only thing that repairs THAT class. So the right signal is not a cleverer predicate, it is **attempting again**: cheap for the class it cannot help, correct for the class it can, self-limiting because the moment an attempt succeeds there is a query again. `namedDocumentsReachable()` is DELETED. **CORRECTED: the claim that this made the module smaller than round 2 left it was FALSE** - measured, `use-shape-query.ts` went 534 -> 563 lines and 203 -> 204 non-comment across that round, and is 664 / 232 now. It has grown at every round. One predicate left; the reasoning that had to be written down beside the code grew more than it. Pinned by (v) and, for the cost, by (s), which now asserts both halves (the re-attempt ran, and it spent no round-trip) so it cannot pass vacuously.
|
||||||
|
|
||||||
**Finding 2 - the re-open performed an access under the wrong identity, and my "pre-existing" defence was wrong in kind.** The reviewer is right: re-publishing RE-FILTERS an existing query, while a re-open NEWLY ISSUES a subscribe and a public-store fetch for a document that subscriber named. That is a new action taken on behalf of whoever happens to be connected, not an old one resurfacing, and the end state was `{isSuccess: true, data: []}` for a document the subscriber cannot read - this task's own defect, through the repair's door. Fixed by binding the refusal to a holder: `refusedFor` records WHOSE barrier failed, and a re-attempt is made only while that holder is still the connected one. Pinned by (w), which fails without the binding.
|
**Finding 2 - the re-open performed an access under the wrong identity, and my "pre-existing" defence was wrong in kind.** The reviewer is right: re-publishing RE-FILTERS an existing query, while a re-open NEWLY ISSUES a subscribe and a public-store fetch for a document that subscriber named. That is a new action taken on behalf of whoever happens to be connected, not an old one resurfacing, and the end state was `{isSuccess: true, data: []}` for a document the subscriber cannot read - this task's own defect, through the repair's door. Fixed by binding the refusal to a holder: `refusedFor` records WHOSE barrier failed, and a re-attempt is made only while that holder is still the connected one. Pinned by (w), which fails without the binding.
|
||||||
|
|
||||||
**ESTABLISHED rather than asserted, as asked: an application cannot change identity mid-session.** `ensureIdentity()` is the only published call that settles one, and it delegates to `settleIdentity` -> `resolveIdentity`, whose first act is `const already = getCurrentUser()` - so a second call returns the identity already set. `setCurrentUser` is not exported from `src/index.ts`. So Finding 2's sequence is reachable only from a harness playing several identities on one page. It is fixed anyway, because a new access issued in someone else's name is a defect in kind whatever can reach it, and because the fix costs one comparison.
|
**CORRECTED: "an application cannot change identity mid-session" was FALSE.** `ensureIdentity` does return the already-settled identity, which is what I checked - but that is not the only path. `shared-wallet/access-gate.ts:386` calls `adoptCurrentUser(normalized)` when a REVIVED barrier is confirmed, reached through the published `init()`, with a comment saying in as many words that the value may well not be the one that left. I checked one call site and generalised from it. The identity CAN change under a live page, which is the second reason the holder comparison had to go.
|
||||||
|
|
||||||
**Finding 3 - the determinism claim was false.** `Promise.all` short-circuits on the first rejection to ARRIVE, and a fetch that rejects does not wait for the sync barrier, so it overtakes a refusal on a document named before it. I chose to fix the BEHAVIOUR rather than the claim: `Promise.allSettled` plus a walk in scope order, so the document reported is the first one the caller named that failed, whatever it failed of, and the parallelism is kept. Pinned by (x), which fails on `Promise.all`.
|
**Finding 3 - the determinism claim was false.** `Promise.all` short-circuits on the first rejection to ARRIVE, and a fetch that rejects does not wait for the sync barrier, so it overtakes a refusal on a document named before it. I chose to fix the BEHAVIOUR rather than the claim: `Promise.allSettled` plus a walk in scope order, so the document reported is the first one the caller named that failed, whatever it failed of, and the parallelism is kept. Pinned by (x), which fails on `Promise.all`.
|
||||||
|
|
||||||
@@ -155,13 +155,35 @@ Fix, round 2: the keyring signal RE-OPENS a scope the barrier refused, gated on
|
|||||||
|
|
||||||
Taken deliberately, at the second offer. Removing it would restore, on a published read, a silent `isSuccess` with `data: []` for a document the caller cannot reach - the defect this task exists for, now proven live rather than argued. The escape hatch's own premise is "a stuck error is worse than a silent empty"; after these three repairs there is no stuck error left to weigh against it. Every class that can refuse is re-attempted on the only readiness signal this library has, and the one class that is not - the identity changed under the observable - cannot arise in an application and is answered honestly rather than by acting in someone else's name. The repairs are also subtractive: a circular predicate deleted, a short-circuit replaced by an ordered walk, a flag given the one extra fact it was missing. What remains is a scope refused in a session whose keyring never moves again, which stays `isError` until `refetch()` - the published meaning of `isError` on this surface, and what it already meant for every other failure it carries.
|
Taken deliberately, at the second offer. Removing it would restore, on a published read, a silent `isSuccess` with `data: []` for a document the caller cannot reach - the defect this task exists for, now proven live rather than argued. The escape hatch's own premise is "a stuck error is worse than a silent empty"; after these three repairs there is no stuck error left to weigh against it. Every class that can refuse is re-attempted on the only readiness signal this library has, and the one class that is not - the identity changed under the observable - cannot arise in an application and is answered honestly rather than by acting in someone else's name. The repairs are also subtractive: a circular predicate deleted, a short-circuit replaced by an ordered walk, a flag given the one extra fact it was missing. What remains is a scope refused in a session whose keyring never moves again, which stays `isError` until `refetch()` - the published meaning of `isError` on this surface, and what it already meant for every other failure it carries.
|
||||||
|
|
||||||
|
### ROUND 4 - refuted again, and the recovery changed SHAPE rather than gaining another flag
|
||||||
|
|
||||||
|
Three defects and two false claims, all reproduced here before anything was touched.
|
||||||
|
|
||||||
|
**Finding 1, blocking: the recovery was EDGE-TRIGGERED, so the edge that caused a refusal was the one it could not see.** `refusedFor` was only set after the async barrier, so an `onChange` firing WHILE the barrier crossed found no query and no record and did nothing - and that same change is what made the walk refuse. Reproduced as (y): inert at subscribe, so the barrier skips the public-store ask entirely; the connect burst files the holder's first cap mid-barrier; the walk then refuses; no keyring event is left to come. `refetch()` answers at once, so the error was for nothing.
|
||||||
|
|
||||||
|
**This is the shape that had to change, not the flag.** Rounds 2 and 3 both built recovery as "record that we refused, then react to a later event", and both records were the fault - one gated on a condition that could not become true, one written after the window in which the deciding event arrives. So the recovery stopped depending on edges:
|
||||||
|
|
||||||
|
- **the decision is a LEVEL.** Which named documents the holder may reach is re-read on every publication ({@link publish} calls `namedRefusal()`), never carried from the barrier. A cap arriving turns `isError` into the document through the channel that already existed, and nothing is remembered, so there is no event to miss. This is also what makes the holder question moot: a query opened while somebody else was connected cannot come back to this subscriber as a success it is not entitled to, so the comparison that Finding 2 breaks is not needed at all.
|
||||||
|
- **an attempt that RACED a change notices, instead of waiting.** `open()` samples `CapRegistry.version()` synchronously before the barrier and compares after it fails; a mismatch means it decided on stale ground, so it makes another attempt itself. It terminates: an attempt repeats only because the keyring moved, moves are finite, 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 (`file()` does not notify when the value is unchanged, and `markInPublicStore` does not notify at all - both checked).
|
||||||
|
|
||||||
|
`CapRegistry.version()` is the one new primitive: a counter bumped where `notify` is, with a note saying why a listener alone is not enough.
|
||||||
|
|
||||||
|
**Finding 2, major: the holder comparison broke the ordinary sign-in.** `holderKey()` is `""` while nobody is connected, so a refusal taken before the identity settles - which is where components mount - never matched again once it did. Reproduced as (z). The comparison is GONE, replaced by the publish-time check above.
|
||||||
|
|
||||||
|
**Finding 3: "costs one snapshot" held only for the absence class.** Over a FAILED fetch, each unrelated arrival re-asks the broker - that is the repair for that class and it stays - but it also published a fresh snapshot carrying a fresh `Error` for an unchanged state, which contradicts the stability this surface documents. FIXED rather than documented away: `setUnknown` compares by message and keeps the standing snapshot, so subscribers see no update while nothing has changed. Reproduced and pinned by (aa), which asserts snapshot IDENTITY.
|
||||||
|
|
||||||
|
**A defect of my own, found by measuring rather than by review.** The round-4 change made the unit suite intermittently fail - two 5s timeouts in `cold-read-for-document`, about one run in four, where round 3 was stable 4/4. Traced by instrumenting the open registry rather than guessed: `resetOpenedRepos()` abandons in-flight opens WITHOUT settling them, so a caller awaiting one waits out the full 8s fallback on a subscription that has been torn down, then proceeds with its sync state cleared - and `"unknown"` is the LAX reading (`index-deposit.noRowsMeansAbsent` accepts it as a definitive absence). A latent failure-as-absence bug in this task's own family, which the re-attempts made fire. Fixed: a reset now ENDS the opens it abandons and marks them `"timed-out"`, the reading that refuses to call nothing an absence. Pinned by a new test in `open-repo.test.ts`; six full-suite runs are green at the usual 2.4s.
|
||||||
|
|
||||||
|
Two fixtures were also racing themselves and are corrected, with the reason written beside them: `use-shape-query.test.ts`'s fake minted into `did:ng:o:doc<n>`, the same namespace as `wallet-fake.ts`, so two fakes aliased each other's repos in the process-global open registry - the lesson `wallet-fake` already carries at its own scale, one scope wider; and the INERT test asserted `isEnforcing() === false` and then subscribed, while signing in arms the registry a tick later, so it was pinning the schedule. It now runs with nobody signed in, which is inert for the whole test.
|
||||||
|
|
||||||
### Gate
|
### Gate
|
||||||
|
|
||||||
- `bun test` -> `438 pass / 0 fail`, `Ran 438 tests across 40 files` (baseline `421 pass / 0 fail`, measured before any change; +17 new tests, no test deleted or skipped - (s) was REWRITTEN, not weakened: it asserted the targeting predicate that round 3 proved circular, and now asserts the property that is actually true and worth guarding, on both halves so it cannot pass vacuously)
|
- `bun test` -> `442 pass / 0 fail`, `Ran 442 tests across 40 files` (baseline `421 pass / 0 fail`, measured before any change; +21 new tests, none deleted or skipped). **Run six times: green every time, 2.25-2.53s.** Two tests were REWRITTEN rather than weakened, each because the design under them changed and the old assertion had become a statement about something that is no longer true: (s) asserted the targeting predicate round 3 proved circular, and now asserts that a re-ask is made for the class it can repair and not for the one it cannot, each half guarding the other against vacuity; (w) asserted that no query is opened under another identity, a rule that left two classes terminal, and now asserts the stronger invariant that replaced it - no snapshot ever claims success for a document the CURRENT holder cannot reach.
|
||||||
|
- **Mutation-tested, five ways.** Removing the raced-edge re-attempt kills (y); removing the publish-time refusal kills (w); removing the snapshot-stability guard kills (aa) and (k); removing the re-attempt entirely kills (r)(s)(v)(w)(z); `Promise.all` in place of `allSettled` kills (x). Round 2's circular gate kills (s)(t)(v) and round 3's holder rule kills (w)(z), so the suite now discriminates against both previously-shipped designs. Removing the abandoned-open settle kills the new `open-repo` test.
|
||||||
- `bunx tsc --noEmit -p packages/polyfill/tsconfig.json` -> exit 0
|
- `bunx tsc --noEmit -p packages/polyfill/tsconfig.json` -> exit 0
|
||||||
- `bunx tsc --noEmit -p packages/polyfill/e2e/tsconfig.json` -> exit 0
|
- `bunx tsc --noEmit -p packages/polyfill/e2e/tsconfig.json` -> exit 0
|
||||||
- `python3 ~/.claude/skills/concept/lint.py` -> 0 ERROR (warnings pre-existing; the new leaf carries none)
|
- `python3 ~/.claude/skills/concept/lint.py` -> 0 ERROR (warnings pre-existing; the new leaf carries none)
|
||||||
- polyfill e2e, run at HEAD: `Polyfill e2e summary: 46 passed, 0 failed, 46 total - batch 3.5 min, slowest cold sync 29s`. 43 was the count before this task; three named-scope checks were added and a fourth removed as non-discriminating. NOTE: the broker login is flaky and independent of this work - three of eleven runs in this session died with `[e2e broker login] gave up after 45.0s - the wallet-list screen never changed`, at a different step each time, on the login UI before any library code runs. Every such run was re-run to a clean result.
|
- polyfill e2e, run at HEAD: `Polyfill e2e summary: 46 passed, 0 failed, 46 total - batch 3.5 min, slowest cold sync 29s`. 43 was the count before this task; three named-scope checks were added and a fourth removed as non-discriminating. NOTE: the broker login is flaky and independent of this work - three of twelve runs in this session died with `[e2e broker login] gave up after 45.0s - the wallet-list screen never changed`, at a different step each time, on the login UI before any library code runs. Every such run was re-run to a clean result.
|
||||||
|
|
||||||
**What the e2e PROVES**, and it is the whole of it: against the real broker, a named scope reads for the holder who owns the document; a holder of nothing is refused out loud with `dataLen: 0` rather than told the document is empty; and a public document opens for that same holder from nothing but its reference, through the barrier's own fetch. **What it does NOT prove**: anything about the re-attempt. Every recovery path - the public class repaired by re-asking, the cost of a doomed re-attempt, and the holder a re-attempt is made as - is measured only in the unit suite, against the `CapRegistry`, which is where that mechanism lives and which the e2e's own header says the broker does not implement. Driving the positive recovery live needs a second actor delivering through `inbox.share`, and belongs in the applicative suite.
|
**What the e2e PROVES**, and it is the whole of it: against the real broker, a named scope reads for the holder who owns the document; a holder of nothing is refused out loud with `dataLen: 0` rather than told the document is empty; and a public document opens for that same holder from nothing but its reference, through the barrier's own fetch. **What it does NOT prove**: anything about the re-attempt. Every recovery path - the public class repaired by re-asking, the cost of a doomed re-attempt, and the holder a re-attempt is made as - is measured only in the unit suite, against the `CapRegistry`, which is where that mechanism lives and which the e2e's own header says the broker does not implement. Driving the positive recovery live needs a second actor delivering through `inbox.share`, and belongs in the applicative suite.
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
private notify(): void {
|
||||||
|
this.changes += 1;
|
||||||
for (const l of this.listeners) {
|
for (const l of this.listeners) {
|
||||||
try {
|
try {
|
||||||
l();
|
l();
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ export type SyncState = "syncing" | "synced" | "timed-out";
|
|||||||
const opened = new Set<Nuri>();
|
const opened = new Set<Nuri>();
|
||||||
/** In-flight opens, so concurrent `ensureRepoOpen(nuri)` share one subscription. */
|
/** In-flight opens, so concurrent `ensureRepoOpen(nuri)` share one subscription. */
|
||||||
const inFlight = new Map<Nuri, Promise<void>>();
|
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). */
|
/** Live bootstrap subscriptions, kept for the session (this is what holds repos open). */
|
||||||
const held = new Map<Nuri, Unsubscribe>();
|
const held = new Map<Nuri, Unsubscribe>();
|
||||||
/** Explicit per-nuri sync state — the barrier signal, distinct from `opened`.
|
/** Explicit per-nuri sync state — the barrier signal, distinct from `opened`.
|
||||||
@@ -132,10 +140,26 @@ export function resetOpenedRepos(): void {
|
|||||||
/* ignore */
|
/* 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();
|
opened.clear();
|
||||||
inFlight.clear();
|
inFlight.clear();
|
||||||
|
settleInFlight.clear();
|
||||||
held.clear();
|
held.clear();
|
||||||
syncState.clear();
|
syncState.clear();
|
||||||
|
for (const [nuri, settle] of abandoned) {
|
||||||
|
syncState.set(nuri, "timed-out");
|
||||||
|
settle();
|
||||||
|
}
|
||||||
boundSessionId = null;
|
boundSessionId = null;
|
||||||
OPEN_TIMEOUT_MS = 8000;
|
OPEN_TIMEOUT_MS = 8000;
|
||||||
// Whatever ELSE was still subscribed to a document - an inbox observation, an
|
// 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 () => {
|
const p = (async () => {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
let settled = false;
|
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".
|
// Resolve on the first `State` (the sync BARRIER), marking the nuri "synced".
|
||||||
const onState = (): void => {
|
const onState = (): void => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
@@ -292,6 +323,7 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
|
|||||||
});
|
});
|
||||||
opened.add(nuri);
|
opened.add(nuri);
|
||||||
inFlight.delete(nuri);
|
inFlight.delete(nuri);
|
||||||
|
settleInFlight.delete(nuri);
|
||||||
})();
|
})();
|
||||||
inFlight.set(nuri, p);
|
inFlight.set(nuri, p);
|
||||||
return p;
|
return p;
|
||||||
|
|||||||
@@ -118,7 +118,27 @@
|
|||||||
* surface, it is a worse one - and it broke a guarantee this package publishes, which
|
* 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.
|
* `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" ----------------------------
|
* -- 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
|
* 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;
|
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
|
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
|
||||||
* `scope` - the SDK's own `{ graphs, subjects }`, passed through untouched. See the
|
* `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. */
|
* a change signal that beats `readyPromise` must not be read as an answer. */
|
||||||
let ready = false;
|
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
|
* Only the first is worth attempting again: a `configure` with no `ormSubscription` is
|
||||||
* attempt at this got wrong.
|
* 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
|
* It records no HOLDER, and an earlier version did. Keying the retry on "the same
|
||||||
* or nothing having been tried yet. A `configure` with no `ormSubscription` is not
|
* holder that was refused" looks like the way to stop a re-attempt acting for somebody
|
||||||
* going to be repaired by a cap arriving, so retrying it on every keyring change
|
* who never asked, and it is a trap twice over: the anonymous ring is a holder like any
|
||||||
* would churn for a misconfiguration no amount of retrying repairs.
|
* other, so a refusal taken before the identity settles - which is where components
|
||||||
* - **For WHICH holder.** A re-open is not a re-publication: it issues a subscribe
|
* mount - never matched again once it did; and an identity CAN change under a live page
|
||||||
* and a public-store fetch for a document THIS subscriber named. Deciding to make
|
* (`shared-wallet/access-gate.ts` adopts the identifier a revived barrier confirms), so
|
||||||
* that request from whatever identity happens to be connected when an unrelated cap
|
* the rule left that case terminal too. What actually has to hold is that no snapshot
|
||||||
* lands would perform an access on behalf of somebody who never asked for it - and
|
* ever claims success for a document the CURRENT holder cannot reach, and that is
|
||||||
* leave the original subscriber reading a query opened in another name, which comes
|
* enforced where it belongs, on every publication - see {@link publish}.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
let refusedFor: string | null = null;
|
let barrierRefused = false;
|
||||||
|
|
||||||
function emit(): void {
|
function emit(): void {
|
||||||
for (const l of listeners) {
|
for (const l of listeners) {
|
||||||
@@ -328,6 +361,16 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
|||||||
* initial value, published under `isError`, never under `isSuccess`.
|
* initial value, published under `isError`, never under `isSuccess`.
|
||||||
*/
|
*/
|
||||||
function setUnknown(error: unknown): void {
|
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({
|
setSnapshot({
|
||||||
data: snapshot.data,
|
data: snapshot.data,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
@@ -355,10 +398,49 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
|||||||
return kept as T[];
|
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 {
|
function publish(handle: OrmSubscriptionHandle, gen: number): void {
|
||||||
if (gen !== generation || !ready) return;
|
if (gen !== generation || !ready) return;
|
||||||
|
const refusal = namedRefusal();
|
||||||
|
if (refusal !== null) {
|
||||||
|
setUnknown(refusal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSnapshot({
|
setSnapshot({
|
||||||
data: readable(handle),
|
data: readable(handle),
|
||||||
isPending: false,
|
isPending: false,
|
||||||
@@ -461,13 +543,31 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
|||||||
function open(): void {
|
function open(): void {
|
||||||
closeQuery();
|
closeQuery();
|
||||||
const gen = ++generation;
|
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 () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await reachNamedDocuments();
|
await reachNamedDocuments();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (gen !== generation) return;
|
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);
|
setUnknown(error);
|
||||||
return;
|
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,
|
// 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.
|
// 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
|
// So ATTEMPT AGAIN. There is deliberately no further test of whether the refusal
|
||||||
// further test of whether the refusal has been lifted: see the note below on why
|
// has been lifted, and none of who is asking: see the module header for why the
|
||||||
// the obvious one is circular, and why an attempt is the cheaper question anyway.
|
// obvious gate is circular, why an attempt is the cheaper question anyway, and why
|
||||||
if (refusedFor !== null && refusedFor === getCaps().holderKey()) open();
|
// the holder belongs in {@link publish} rather than here.
|
||||||
|
if (barrierRefused) open();
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
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 { readDocumentSubjects } from "../src/surface/read-document";
|
||||||
import { configure } from "../src/index";
|
import { configure } from "../src/index";
|
||||||
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||||
@@ -55,7 +55,11 @@ const FP = "http://festipod.org/";
|
|||||||
|
|
||||||
function makeFakeNgWithSubscribe(
|
function makeFakeNgWithSubscribe(
|
||||||
triplesByDoc: Record<string, Array<[string, string]>>,
|
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 subscribed = new Set<string>();
|
||||||
const subscribeCallOrder: string[] = [];
|
const subscribeCallOrder: string[] = [];
|
||||||
|
|
||||||
@@ -65,7 +69,7 @@ function makeFakeNgWithSubscribe(
|
|||||||
subscribeCallOrder.push(nuri);
|
subscribeCallOrder.push(nuri);
|
||||||
// Simulate initial State push (synchronously deferred so the subscription
|
// Simulate initial State push (synchronously deferred so the subscription
|
||||||
// setup promise path in ensureRepoOpen can resolve it).
|
// setup promise path in ensureRepoOpen can resolve it).
|
||||||
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||||
return () => {}; // unsubscribe fn
|
return () => {}; // unsubscribe fn
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,6 +172,38 @@ describe("ensureRepoOpen", () => {
|
|||||||
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
|
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 () => {
|
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
|
||||||
const ng = makeFakeNgWithSubscribe({
|
const ng = makeFakeNgWithSubscribe({
|
||||||
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
|
"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;
|
let docCounter = 0;
|
||||||
const subs: SubRec[] = [];
|
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 sparql_update = mock(async (...a: unknown[]) => {
|
||||||
const query = a[1] as string;
|
const query = a[1] as string;
|
||||||
@@ -713,52 +720,49 @@ describe("useShapeQuery", () => {
|
|||||||
unsub();
|
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 ng = makeFake();
|
||||||
const orm = makeOrm();
|
const orm = makeOrm();
|
||||||
inject(ng, orm);
|
inject(ng, orm);
|
||||||
setCurrentUser("alice");
|
setCurrentUser("alice");
|
||||||
const note = await createEntityDoc("alice", "protected");
|
// Two public documents, and what separates them is the BROKER, not the library: one
|
||||||
seedEvent(orm, note, `${note}:q:one`, "Shared");
|
// 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");
|
setCurrentUser("bob");
|
||||||
const obs = useShapeQuery(EventShape, { graphs: [note] });
|
ng._unreachable.add(cannotBeAsked);
|
||||||
let notes = 0;
|
const asks = (doc: string): number =>
|
||||||
const unsub = obs.subscribe(() => {
|
ng.sparql_query.mock.calls.filter((c) => c[3] === doc).length;
|
||||||
notes += 1;
|
|
||||||
});
|
|
||||||
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 memoised = useShapeQuery(EventShape, { graphs: [answers] });
|
||||||
const asks = (): number =>
|
const unsubA = memoised.subscribe(() => {});
|
||||||
ng.sparql_query.mock.calls.filter((c) => c[3] === note).length;
|
const outage = useShapeQuery(EventShape, { graphs: [cannotBeAsked] });
|
||||||
const afterRefusal = asks();
|
const unsubB = outage.subscribe(() => {});
|
||||||
const notesAfterRefusal = notes;
|
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
|
// Three unrelated arrivals. Both scopes re-attempt on each - there is deliberately no
|
||||||
// holder hold every named document's cap yet", so that an unrelated cap would not
|
// predicate deciding which is worth it, because every predicate tried so far left a
|
||||||
// buy a round-trip. That gate was CIRCULAR for a document in a public store - the
|
// class permanently stuck. The MEMO is what makes that affordable, and the two
|
||||||
// only channel that can ever file its cap is the very fetch the gate was blocking -
|
// counts below are the proof, each the other's guard against vacuity: a re-ask that
|
||||||
// and an adversarial pass proved the class unrecoverable. The gate is gone: every
|
// cannot repair anything costs nothing, and the one that can is actually made.
|
||||||
// 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)).
|
|
||||||
for (let round = 0; round < 3; round++) {
|
for (let round = 0; round < 3; round++) {
|
||||||
getCaps().learn(mintCap(`did:ng:o:unrelated-${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
|
expect(asks(answers)).toBe(answeredAt); // an absence was read, and is remembered
|
||||||
// the count untouched. The republication is what proves the barrier was re-run.
|
expect(asks(cannotBeAsked)).toBeGreaterThan(outageAt); // a failure is asked again
|
||||||
expect(notes).toBeGreaterThan(notesAfterRefusal);
|
unsubA();
|
||||||
expect(obs.getSnapshot().isError).toBe(true);
|
unsubB();
|
||||||
expect(asks()).toBe(afterRefusal);
|
|
||||||
unsub();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("(t) named documents cross the barrier TOGETHER, so N that never sync cost one wait, not N", async () => {
|
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 ng = makeFake();
|
||||||
const orm = makeOrm();
|
const orm = makeOrm();
|
||||||
inject(ng, orm);
|
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";
|
const early = "did:ng:o:inert-named";
|
||||||
seedEvent(orm, early, `${early}:q:one`, "Early");
|
seedEvent(orm, early, `${early}:q:one`, "Early");
|
||||||
expect(getCaps().isEnforcing()).toBe(false);
|
expect(getCaps().isEnforcing()).toBe(false);
|
||||||
@@ -858,7 +868,7 @@ describe("useShapeQuery", () => {
|
|||||||
unsub();
|
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 ng = makeFake();
|
||||||
const orm = makeOrm();
|
const orm = makeOrm();
|
||||||
inject(ng, orm);
|
inject(ng, orm);
|
||||||
@@ -869,26 +879,44 @@ describe("useShapeQuery", () => {
|
|||||||
setCurrentUser("bob");
|
setCurrentUser("bob");
|
||||||
const obs = useShapeQuery(EventShape, { graphs: [hers] });
|
const obs = useShapeQuery(EventShape, { graphs: [hers] });
|
||||||
const unsub = obs.subscribe(() => {});
|
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);
|
expect(obs.getSnapshot().isError).toBe(true);
|
||||||
|
|
||||||
// The page becomes Alice. No application can do this - `ensureIdentity` is the only
|
// The page becomes Alice, which a live page CAN do: `shared-wallet/access-gate.ts`
|
||||||
// published call that settles an identity and it returns the one already set
|
// adopts the identifier a revived barrier confirms. The re-attempt is deliberately
|
||||||
// (`access-gate.resolveIdentity` reads `getCurrentUser()` first) - but the re-open is
|
// not keyed on the holder - that rule left a refusal taken before the identity
|
||||||
// a NEW access, not a re-filtering of an existing one, and a new access must never be
|
// settled, and one taken before an identity change, both permanently stuck - so a
|
||||||
// issued on behalf of somebody who did not ask for it.
|
// query may well be opened while she is the one connected.
|
||||||
setCurrentUser("alice");
|
setCurrentUser("alice");
|
||||||
getCaps().learn(mintCap("did:ng:o:unrelated-arrival"));
|
getCaps().learn(mintCap("did:ng:o:unrelated-arrival"));
|
||||||
for (let i = 0; i < 10; i++) await tick();
|
for (let i = 0; i < 12; 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
|
// ...and back under Bob the answer has to be the refusal. What guarantees it is that
|
||||||
// this whole task exists to remove, arriving by the repair's own door.
|
// 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");
|
setCurrentUser("bob");
|
||||||
getCaps().learn(mintCap("did:ng:o:unrelated-arrival-2"));
|
getCaps().learn(mintCap("did:ng:o:unrelated-arrival-2"));
|
||||||
for (let i = 0; i < 10; i++) await tick();
|
for (let i = 0; i < 12; i++) await tick();
|
||||||
expect(obs.getSnapshot().isSuccess).toBe(false);
|
const snap = obs.getSnapshot();
|
||||||
expect(obs.getSnapshot().isError).toBe(true);
|
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();
|
unsub();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -916,6 +944,111 @@ describe("useShapeQuery", () => {
|
|||||||
unsub();
|
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 ------------------------------------------------------------
|
// -- teardown ------------------------------------------------------------
|
||||||
|
|
||||||
it("(k) the last unsubscribe closes the shape query", async () => {
|
it("(k) the last unsubscribe closes the shape query", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user