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"]],
+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();
});
@@ -221,6 +221,25 @@ function inject(ng: ReturnType<typeof makeFake>, orm: FakeOrm) {
resetCaps();
}
/**
* A SECOND `configure(...)` over a live page - exactly what {@link inject} does, minus every
* reset. An application that calls `configure` twice resets nothing itself, and a test that
* did would be performing the repair it is meant to be checking.
*/
function reconfigure(ng: ReturnType<typeof makeFake>, orm: FakeOrm): void {
const forwarded: Record<string, unknown> = { ...ng };
if (ng._noSubscribe) delete forwarded.doc_subscribe;
configure({
ng: forwarded as unknown as NgLike,
useShape: (() => {}) as unknown as UseShapeLike,
ormSubscription: orm.query,
});
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
});
}
const tick = () => new Promise((r) => setTimeout(r, 5));
/**
@@ -1211,6 +1230,57 @@ describe("useShapeQuery", () => {
unsub();
});
// A SECOND `configure(...)`, which is a published call an application makes - it tears the
// subscription layer down (`resetDocSubscriptions`) and says nothing to the open registry.
// Until 2026-09-24 the registry kept its own records of "this document is open" and "its
// barrier was crossed", so a named scope went on publishing `isSuccess` against a
// subscription that no longer existed, and nothing - `refetch()` included - could re-open it.
//
// Deliberately NOT `inject`: this re-configures and nothing else, because that is what an
// application does. Resetting the registry here would be the test doing the repair itself.
it("(gg) a second configure() does not let a named scope answer from a subscription that is gone", async () => {
const ngA = makeFake();
const orm = makeOrm();
inject(ngA, orm);
setCurrentUser("zoe");
const mine = await createEntityDoc("zoe", "protected");
seedEvent(orm, mine, `${mine}:q:one`, "Present");
const obs = useShapeQuery(EventShape, { graphs: [mine] });
const unsub = obs.subscribe(() => {});
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(titles(obs.getSnapshot().data)).toEqual(["Present"]);
expect(getSyncState(mine)).toBe("synced");
// A second SDK, which does not push a `State`: this session has not heard from the
// document, and has no standing to call it anything.
const ngB = makeFake({ pushState: false });
reconfigure(ngB, orm);
setOpenTimeoutForTests(30);
setCurrentUser("zoe");
obs.refetch();
for (let i = 0; i < 40 && obs.getSnapshot().isPending === false; i++) await tick();
// The defect, as an application saw it: `{isPending: false, isSuccess: true}` for a
// document this session never subscribed - an answer asserting authority it does not have.
const withheld = obs.getSnapshot();
expect(withheld.isSuccess).toBe(false);
expect(withheld.isError).toBe(false);
expect(withheld.isPending).toBe(true);
// ...and the re-open is really attempted, against the SDK that is injected NOW - measured
// as 0 on the previous code, because the registry's own record short-circuited it.
expect(ngB.doc_subscribe.mock.calls.filter((c) => String(c[0]) === mine).length).toBe(1);
// And the line that must not break: authority RETURNS with the barrier, and a document
// that has synced answers a success - here holding something, and in (h)/(m)/(p) holding
// nothing. The repair is never to turn a withheld read into an error.
ngB._pushStateNow(mine);
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(getSyncState(mine)).toBe("synced");
expect(titles(obs.getSnapshot().data)).toEqual(["Present"]);
unsub();
});
it("(ff) a refusal that has been lifted is never published again as the answer", async () => {
const ng = makeFake({ pushState: false });
const orm = makeOrm();