feat!: une seule reponse a « ce document est-il ouvert dans cette session »

Trois reponses coexistaient et pouvaient se contredire : un ensemble de
souscriptions bootstrap et une carte d'etat de barriere dans `open-repo`, et les
auditeurs du fan-out dans `surface/subscribe`. Les deux premieres survivaient a
la disparition de la troisieme, parce que `resetDocSubscriptions()` - ce que fait
le `configure()` publie - largue ses auditeurs sans rien dire a personne.

Il n'en reste qu'une, l'etat de l'unique souscription reelle du document, exposee
par `docChannel(nuri)`. `open-repo` n'enregistre plus que l'auditeur qu'il a pose
- « est-ce que j'ecoute encore », rien de plus - et derive `getSyncState` a chaque
lecture, si bien qu'aucune reponse ne peut survivre au canal qu'elle decrit.
Disparaissent : `held`, `syncState`, `inFlight`, `settleInFlight` et le compteur
de generation, remplace par l'identite de l'enregistrement.

Les deux echecs prouves deviennent des tests qui echouent sans le changement :
un second `configure()` apres une synchro reussie (`"synced"` au lieu de
`"timed-out"`, zero re-souscription, et `isSuccess` cote application), et un
etablissement encore en cours quand la couche de souscription est demontee - cas
que le canal `onSetupFailed` ne peut pas rattraper, puisqu'il ne rapporte que la
tentative encore attendue.

La ligne qui ne casse pas : un document synchronise qui ne contient rien repond
toujours un succes a zero objet. `1.0.0-dev.10`, les deux non-garanties
nommees sont retirees et la garantie livree porte le test qui l'epingle.
This commit is contained in:
Sylvain Duchesne
2026-09-24 12:57:43 +02:00
parent 2da26c5c4d
commit de9b8a0d67
10 changed files with 574 additions and 191 deletions
+73 -1
View File
@@ -63,6 +63,14 @@
* fire, and `inbox.watch`'s "fires once immediately" would silently stop being true.
* The real subscription is torn down when the LAST listener leaves.
*
* ── This module OWNS "is this document open in this session" ───────────────
* Because the subscription is the only thing that makes a document open, the fan-out is the
* only honest answer to whether one is - and {@link docChannel} is how the rest of the
* package asks. Nobody keeps a copy: `emulated-verifier/open-repo.ts` kept two (a set of
* live bootstrap subscriptions, and a per-document barrier state), and both outlived the
* channel they described the moment `resetDocSubscriptions` dropped it, which is how a
* document with no subscription at all came to report itself synced.
*
* ── What SHARING one subscription must not cost a caller ───────────────────
* Three things a per-caller `doc_subscribe` gave for free, and that the fan-out has to give
* back deliberately — each of them was lost when it was introduced on 2026-08-17:
@@ -214,6 +222,63 @@ interface DocFanOut {
*/
const fanOuts = new Map<Nuri, DocFanOut>();
/**
* What the one real subscription for a document IS, right now.
*
* - `"none"` nothing stands: no listener holds this document, or the establish that
* was standing FAILED and the next joiner will make its own;
* - `"opening"` an establish is in flight - the call has been made and has not answered,
* so there is no channel yet and there may never be one;
* - `"open"` the `doc_subscribe` call ANSWERED and this fan-out is still the one
* waiting on it: pushes arrive here.
*/
// @provenance DocChannelState kind=invention level=none ref=none — names the states of this module's own fan-out; upstream a caller holds its own subscription and has nothing to ask
export type DocChannelState = "none" | "opening" | "open";
/**
* THE answer to "is this document open in this session", and the only one.
*
* Read it, never copy it. `emulated-verifier/open-repo.ts` used to keep records of its own
* - a set of live bootstrap subscriptions and a per-document barrier state - and both
* survived a teardown of the fan-out they described: `resetDocSubscriptions` drops every
* listener without a word to anybody, so a document whose channel had gone was still
* recorded as open and as SYNCED, nothing re-subscribed it, and a zero-row read over it
* answered a success. Three records of one fact, and they disagreed. There is one now, it
* is this one, and it cannot outlive the channel it describes because it IS the channel.
*/
export interface DocChannel {
/** Whether a channel stands, and how far it got. */
readonly state: DocChannelState;
/**
* Has a `State` push arrived on the channel standing NOW?
*
* The first `State` is the sync barrier (CONTRACT 3): past it, presence is guaranteed and
* absence definitive, so a zero-row read MEANS zero. It is {@link DocFanOut.lastState}
* that says so - the one the fan-out replays to a late joiner - which is exactly the right
* record, because it is forgotten on the two events that invalidate a barrier: the
* channel being released, and the session changing under it ({@link resubscribeDocs}).
*/
readonly barrierCrossed: boolean;
}
/**
* The state of `nuri`'s one real subscription - see {@link DocChannel}.
*
* Internal. Exported for `emulated-verifier/open-repo.ts`, which asks it instead of
* remembering the answer, and for nobody else; neither entry point re-exports it.
*/
// @provenance docChannel kind=invention level=none ref=none — a question about this module's own fan-out, which exists because a branch upstream holds a single subscriber; there is nothing to align it with
export function docChannel(nuri: Nuri): DocChannel {
const entry = fanOuts.get(nuri);
if (!entry) return { state: "none", barrierCrossed: false };
// `establishing` says an attempt STANDS (it is left true once one has succeeded, see
// {@link DocFanOut.establishing}); `realUnsub` says that attempt's call has answered. The
// two together are the only thing that separates "asked" from "open" - and recording the
// first as if it were the second is the defect this accessor exists to make impossible.
const state: DocChannelState = !entry.establishing ? "none" : entry.realUnsub !== null ? "open" : "opening";
return { state, barrierCrossed: entry.lastState !== null };
}
/** Hand one push to one listener, isolating a throwing handler from the others. */
function deliver(nuri: Nuri, listener: DocListener, resp: DocChange, type: DocChangeType): void {
try {
@@ -420,7 +485,14 @@ async function establish(
if (typeof unsub === "function") unsub();
return;
}
entry.realUnsub = typeof unsub === "function" ? unsub : null;
// A function is what upstream resolves to, and it is the cancel. When the injected SDK
// hands back something else there is nothing to cancel WITH - but the call has still
// ANSWERED, and that is what {@link docChannel} reads off this field. Left `null`, the
// channel would read `"opening"` for the rest of the session and every `ensureRepoOpen`
// would take out a fresh bootstrap listener over a document that is perfectly open. A
// no-op costs exactly what `null` cost before: {@link releaseFanOut} returned early on
// `null` rather than cancelling, so nothing is lost by calling nothing instead.
entry.realUnsub = typeof unsub === "function" ? unsub : (): void => {};
} catch (error) {
reportSetupFailure(nuri, entry, error, attempt);
}