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
@@ -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();