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
+106
View File
@@ -22,6 +22,7 @@
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, getSyncState, resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
import { readDocumentSubjects } from "../src/surface/read-document";
import { docChannel, subscribeDoc } from "../src/surface/subscribe";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
@@ -261,6 +262,111 @@ describe("ensureRepoOpen", () => {
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2);
});
// -- ONE answer to "is this document open in this session" ---------------
//
// Both of these are the same defect: a record of openness kept HERE, which outlives the
// subscription it describes because `surface/subscribe.ts` drops its listeners without
// telling anybody. `configure()` is PUBLISHED and calls `resetDocSubscriptions()`, so this
// is a sequence an application performs, not a montage.
it("a second configure() leaves NOTHING claiming the document is open or synced", async () => {
const ngA = makeFakeNgWithSubscribe({ "did:ng:o:reconf": [[TYPE, `${FP}Event`]] });
inject(ngA);
await ensureRepoOpen("did:ng:o:reconf");
expect(getSyncState("did:ng:o:reconf")).toBe("synced");
// A second SDK. The subscription behind that `"synced"` is gone with the first one, so the
// barrier it reports was crossed against a verifier nobody is talking to any more. Until
// 2026-09-24 this module kept saying `"synced"` - which is AUTHORITY, a zero-row read over
// it being a definitive absence - and its own `held` entry then short-circuited every
// re-open for the rest of the session, so `refetch()` had no exit either.
const ngB = makeFakeNgWithSubscribe({ "did:ng:o:reconf": [[TYPE, `${FP}Event`]] });
inject(ngB);
expect(getSyncState("did:ng:o:reconf")).toBe("timed-out");
// ...and the re-open actually happens, against the SDK that is now injected.
await ensureRepoOpen("did:ng:o:reconf");
expect(ngB.doc_subscribe).toHaveBeenCalledTimes(1);
expect(getSyncState("did:ng:o:reconf")).toBe("synced");
});
// The second face, and the one the failure channel cannot reach.
//
// `subscribe.ts` reports a refused establish only for the attempt it is still WAITING ON, so
// a rejection that arrives after `resetDocSubscriptions` released the fan-out is logged and
// nothing else. A record written when the subscribe was REQUESTED therefore stood for the
// whole session with nothing able to clear it: measured on the previous code, zero subscribes
// under the new SDK, for ever. The repair is that nothing here records openness at all.
it("an establish still outstanding when the subscription layer is torn down leaves no claim", async () => {
let refuse: ((error: Error) => void) | null = null;
const doc_subscribe = mock(
async () =>
await new Promise<never>((_resolve, reject) => {
refuse = reject;
}),
);
const ngA = {
doc_subscribe,
sparql_query: mock(async () => ({ results: { bindings: [] } })),
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
};
inject(ngA as unknown as ReturnType<typeof makeFakeNgWithSubscribe>);
setOpenTimeoutForTests(20);
// Stops waiting on the bounded fallback, with the subscribe STILL outstanding: at this
// instant nothing is true yet, and that is precisely when the old code recorded a hold.
await ensureRepoOpen("did:ng:o:outstanding");
expect(doc_subscribe).toHaveBeenCalledTimes(1);
const ngB = makeFakeNgWithSubscribe({});
inject(ngB); // configure() -> resetDocSubscriptions(), mid-establish
// The first SDK finally refuses - into a fan-out that no longer exists, so no caller is
// told. Nothing may depend on being told.
refuse!(new Error("BrokerError: late refusal"));
await new Promise((r) => setTimeout(r, 5));
setOpenTimeoutForTests(20);
await ensureRepoOpen("did:ng:o:outstanding");
expect(ngB.doc_subscribe).toHaveBeenCalledTimes(1);
expect(getSyncState("did:ng:o:outstanding")).toBe("synced");
});
// The other half of the same question, and it is why "the channel is open" is not the whole
// answer either.
//
// A reset releases the bootstrap listener, which is what kept the repo open for the session.
// If something ELSE was following the document, the fan-out survives and `resubscribeDocs`
// re-opens its channel - so the channel is `"open"` again while this module holds nothing.
// Skipping the work there would leave the document held by a listener that is free to leave,
// and the read would not wait for the new session's barrier either.
it("a reset leaves the document to be HELD again, even though its channel is open", async () => {
const ng = makeFakeNgWithSubscribe({});
inject(ng);
const doc = "did:ng:o:alsowatched";
// Somebody else follows this document - an inbox observation, or an application's own
// subscription. It is not this module's listener and it does not hold the repo for it.
const appUnsub = subscribeDoc(doc, () => {});
await ensureRepoOpen(doc);
expect(getSyncState(doc)).toBe("synced");
// The session changes: the bootstrap listener goes, the other one stays, and the fan-out's
// channel is re-opened against the new session with its barrier forgotten.
resetOpenedRepos();
await new Promise((r) => setTimeout(r, 5));
expect(docChannel(doc).state).toBe("open");
await ensureRepoOpen(doc);
expect(getSyncState(doc)).toBe("synced");
// The other listener leaves. The document must still be open, because the bootstrap open is
// holding it - which it can only do if the call above actually took a listener out.
appUnsub();
expect(docChannel(doc).state).toBe("open");
expect(getSyncState(doc)).toBe("synced");
});
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],