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:
@@ -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"]],
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user