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
+76
View File
@@ -1,6 +1,8 @@
import { test, expect, mock, afterAll } from "bun:test";
import {
docChangeType,
docChannel,
resetDocSubscriptions,
resubscribeDocs,
subscribeDoc,
subscribeDocReportingSetupFailure,
@@ -712,3 +714,77 @@ test("served in the INVERTED order, the document goes silently dead — nothing
alsoStop();
stop();
});
// -- the ONE answer to "is this document open in this session" ---------------
//
// `docChannel` is that answer, and the whole of what it has to get right is telling an
// UNANSWERED call from an open channel. Every record of openness this package has kept
// elsewhere got that wrong in the same way: written when the subscribe was REQUESTED, so it
// meant "we asked". `emulated-verifier/open-repo.ts` used to keep two of them and they
// outlived the subscription they described; it asks this instead.
test("docChannel says OPENING while the call is unanswered, OPEN once it is served", async () => {
// Accepted and not yet SERVED: the call has been placed and the broker has said nothing.
const ng = inject(undefined, undefined, new Set([A]));
expect(docChannel(A).state).toBe("none"); // nobody is listening yet
const unsub = subscribeDoc(A, () => {});
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
// The call is out and there is no channel. Reading this as `"open"` is the defect: a caller
// skips the work it still has to do, and a document that may never be subscribed is treated
// as one that is.
expect(docChannel(A).state).toBe("opening");
expect(docChannel(A).barrierCrossed).toBe(false);
ng.serveQueued(0);
await tick();
expect(docChannel(A).state).toBe("open");
// ...and the barrier is the fan-out's own record of the first `State`, so it comes with it.
expect(docChannel(A).barrierCrossed).toBe(true);
unsub();
expect(docChannel(A).state).toBe("none");
});
test("docChannel says NONE for a call the broker refused, and for a torn-down fan-out", async () => {
const ng = inject(new Set([B]));
const unsub = subscribeDoc(B, () => {});
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
// Refused: nothing stands, and the next joiner must be free to make its own attempt.
expect(docChannel(B).state).toBe("none");
expect(docChannel(B).barrierCrossed).toBe(false);
unsub();
// A fan-out dropped from under its listeners - what `resetDocSubscriptions` does, which
// `configure()` calls. The answer follows the channel because it IS the channel.
const ng2 = inject();
const live = subscribeDoc(A, () => {});
await tick();
expect(docChannel(A).state).toBe("open");
expect(ng2.doc_subscribe).toHaveBeenCalledTimes(1);
resetDocSubscriptions();
expect(docChannel(A).state).toBe("none");
expect(docChannel(A).barrierCrossed).toBe(false);
live();
});
// An injected SDK that answers with something other than a cancel function still ANSWERED, and
// the channel is open. Read as "not open", every bootstrap open over that document would take
// out a fresh listener and wait the barrier again, for the rest of the session - over a
// document that is perfectly subscribed. Upstream resolves to a function; a consumer's own
// shim need not, and this is the one place that difference could be mistaken for a failure.
test("a doc_subscribe that answers without a cancel function is still an OPEN channel", async () => {
const doc_subscribe = mock(async (_n: string, _s: unknown, cb: (r: unknown) => void) => {
queueMicrotask(() => cb({ V0: { State: {} } }));
return undefined; // no cancel to hand back
});
configure({ ng: { doc_subscribe } as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION });
const unsub = subscribeDoc(A, () => {});
await tick();
expect(doc_subscribe).toHaveBeenCalledTimes(1);
expect(docChannel(A).state).toBe("open");
expect(docChannel(A).barrierCrossed).toBe(true);
unsub();
});