fix!: le registre d-ouverture n-a plus qu-un seul sens, et il cesse de mentir

Trois constats, un seul defaut sous trois visages : un etat enregistre comme vrai
qui ne l'est pas, sans moyen de se corriger. Deux des trois vivaient dans
`open-repo.ts`. Cinq tours sont alles dans l'appelant ; c'est le teneur de
registre qui mentait.

CE QUE LE REGISTRE VEUT DIRE, ecrit dans l'en-tete. `held` est le SEUL registre
d'ouverture : cette session detient un abonnement vivant a ce document. Il y avait
a cote un `opened` voulant dire « on a appele `doc_subscribe` une fois et on a
cesse d'attendre », et ce sens plus lache mentait de trois facons : il survivait a
un abonnement REFUSE par le courtier (donc la reprise que `subscribe.ts` offre
etait bloquee a jamais), il survivait a une reinitialisation (donc une nouvelle
session court-circuitait sur l'enregistrement de la precedente), et il laissait un
document annoncer une barriere jamais franchie. `opened` est supprime.

Les trois consequences, desormais vraies par construction :
- un abonnement qui a ECHOUE n'enregistre rien - `onSetupFailed`, le canal que
  `subscribe.ts` offre exactement pour cela, est enfin branche - donc l'appelant
  suivant s'abonne a nouveau ;
- une reinitialisation efface l'enregistrement ET DECLASSE l'etat de synchro :
  une barriere franchie dans la session qui vient de disparaitre n'en est pas une
  que celle-ci a franchie ;
- `"unknown"` ne veut plus dire qu'une chose : aucune barriere n'etait attendue ici
  (jamais demande, ou un `ng` sans `doc_subscribe`). Jamais « on a oublie ».

Cote appelant, un seul changement, et c'est le troisieme constat : quand un
document nomme n'a pas repondu, `publish` PUBLIE « la question reste ouverte » au
lieu de sortir en laissant en place l'instantane precedent. Sortir laissait un
refus DEJA LEVE a l'affichage : l'application lisait « vous ne detenez pas la
capacite de ce document » a propos d'un document qu'elle detient desormais.

Deux tests portaient l'ancien sens et sont corriges avec leur raison : celui du
`ng` sans `doc_subscribe` (le compteur d'ids du faux courtier redemarrait a chaque
test, donc deux tests aliasaient leurs depots - la lecon de `wallet-fake` a
l'echelle du fichier), et celui de la lecture froide, ou « timed-out » est
desormais ce que dit « cette page ne l'a pas confirme ».
This commit is contained in:
Sylvain Duchesne
2026-09-24 11:34:10 +02:00
parent 2571f8e738
commit 6b2b8ab42a
6 changed files with 218 additions and 32 deletions
@@ -197,6 +197,8 @@ Where a call must first find out whether something already exists — a document
**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 named scope goes back to PENDING when the session changes under it.** Signing in again inside the same page gives a new session, whose repos this package has synced nothing for yet - so a named scope that was answering returns to its loading state until the documents it names are confirmed again, and `refetch()` is what confirms them. Withholding is deliberate: a barrier crossed in the session that has just gone is not one the new session has crossed, and the alternative is answering from a subscription nothing re-established. This is the one case where a read that had answered stops answering, and it does not arise on an ordinary page load - there the whole page is new.
**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. The wait itself is bounded internally, so a document that never answers does not hold the open for ever - but the READ over it stays pending, because a barrier that was never crossed is not authority to call anything empty.
@@ -278,7 +280,7 @@ What each level means here, in this package's own terms:
`1.0.0-dev.9` is a **major**, and all of it is one rule: **a read that could not reach its document now FAILS, where it used to answer as though the document were empty.** Nothing is added, nothing is renamed, nothing is removed from the entry - three existing calls narrow.
- **`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 document that has not SYNCED keeps the read pending**, where it used to publish the same empty success. Reaching a document and hearing back from it are two questions, and only the first was being asked. An interface must therefore expect a named scope to sit in its loading state while a document it named is not answering, rather than reading an empty success as "nothing there".
- **A named document that has not SYNCED keeps the read pending**, where it used to publish the same empty success. Reaching a document and hearing back from it are two questions, and only the first was being asked. An interface must therefore expect a named scope to sit in its loading state while a document it named is not answering, rather than reading an empty success as "nothing there". The same applies after an in-page re-login, which returns a named scope to pending until its documents are confirmed under the new session - see `## Guarantees`.
- **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.** 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.