fix!: joindre un document n-est pas en avoir des nouvelles, et une reinit invalide ce qu-elle abandonne

Deux defauts, aucun des deux sur la reprise - elle a passe cette revue intacte et
n'a pas ete rouverte.

1. La barriere verifiait qu'un document nomme peut etre JOINT, jamais qu'il a
   REPONDU. Elle attendait `ensureRepoOpen` puis demandait `assertMayReach`, qui
   est la question de la capacite, et ne consultait pas l'etat de synchro que
   l'ouverture venait de produire. Un document dont l'abonnement a reussi mais dont
   le premier `State` n'est jamais venu publiait donc `isSuccess` avec `data: []` :
   le defaut d'origine, dans le cas precis pour lequel la barriere a ete ajoutee.

   La verification rejoint le reste de la decision, dans `publish`, donc rejouee a
   chaque publication au lieu d'etre memorisee. Les trois valeurs, enumerees avant
   d'ecrire la condition : `"synced"` fait autorite - zero ligne VEUT DIRE zero
   objet, la ligne tenue depuis cinq revues -, `"timed-out"` et `"syncing"` non, et
   `"unknown"` PASSE deliberement, parce qu'il veut dire « pas de semantique de
   `State` sur ce `ng` » et que le traiter autrement est exactement la regression
   du cas inerte livree aux tours 1 et 3.

   `isPending` plutot que `isError`, des deux que le contrat autorise : rien n'a
   echoue, il manque l'autorite de dire que le document est vide.

   Le test (t) EPINGLAIT le defaut - il attendait `isSuccess` apres trois
   barrieres expirees. Corrige. Ce n'est pas un test affaibli : l'ancienne
   assertion etait fausse.

   Et un trou dans ma propre suite, trouve par mutation : inverser la condition
   pour refuser `"unknown"` passait tous les tests, faute d'un `ng` SANS
   `doc_subscribe` nulle part. (cc) le couvre.

2. Regression de mon correctif du tour 4. Terminer une ouverture abandonnee
   faisait tourner sa continuation juste apres que la reinit avait vide les
   tables, donc elle ecrivait une entree `opened` perimee dans le registre neuf -
   et toute ouverture ulterieure de ce nuri court-circuitait dessus. Le depot
   n'etait jamais abonne dans la nouvelle session. Une epoque de registre corrige
   cela : une reinit l'incremente, une continuation dont l'epoque ne correspond
   plus n'ecrit rien. Une reinit INVALIDE ce qu'elle abandonne, en plus de le
   terminer.

`getSyncState` reste interne, et la doctrine cesse de l'offrir : elle pointait sur
un symbole qu'aucun appelant ne peut importer.
This commit is contained in:
Sylvain Duchesne
2026-09-24 11:03:07 +02:00
parent 8c890411bb
commit 4af0be433d
7 changed files with 224 additions and 15 deletions
+93 -7
View File
@@ -27,7 +27,7 @@ import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser, getCaps } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
import { getSyncState, resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
import { mintCap } from "../src/emulated-verifier/caps";
import { makeOrm, type FakeOrm } from "./orm-fake";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
@@ -59,7 +59,7 @@ interface SubRec { nuri: string; cb: (r: unknown) => void }
* The stateful fake `ng` - the shim account, the per-scope index and the document
* creation the page does around the read. It is NOT what answers the read any more.
*/
function makeFake(options: { pushState?: boolean } = {}) {
function makeFake(options: { pushState?: boolean; subscribe?: boolean } = {}) {
/** Anchors whose anchored read throws. Mutable after boot, so a suite builds a healthy
* world first and breaks only the one call it is about. */
const unreachable = new Set<string>();
@@ -172,12 +172,27 @@ function makeFake(options: { pushState?: boolean } = {}) {
return () => {};
});
return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads, _unreachable: unreachable };
return {
doc_create,
sparql_update,
sparql_query,
doc_subscribe,
_quads: quads,
_unreachable: unreachable,
// An `ng` with NO `doc_subscribe` AT ALL - the documented no-op path, where there are
// no `State` semantics and so no barrier to have missed. `wallet-fake.ts` is like this
// by default, and so is any consumer whose `ng` does not carry the member. The member
// is withheld in `inject` rather than here, so the two suites that count subscribes
// keep a mock to count.
_noSubscribe: options.subscribe === false,
};
}
function inject(ng: ReturnType<typeof makeFake>, orm: FakeOrm) {
const forwarded: Record<string, unknown> = { ...ng };
if (ng._noSubscribe) delete forwarded.doc_subscribe;
configure({
ng: ng as unknown as NgLike,
ng: forwarded as unknown as NgLike,
useShape: (() => {}) as unknown as UseShapeLike,
ormSubscription: orm.query,
});
@@ -793,9 +808,22 @@ describe("useShapeQuery", () => {
// documents that never push.
expect(subscribesTo()).toBe(3);
// ...and it still answers, once the fallbacks have fired.
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(titles(obs.getSnapshot().data)).toEqual(["A", "B", "C"]);
// CORRECTED 2026-09-24. This used to wait for `isSuccess` and assert the three titles,
// which PINNED A DEFECT: the fallbacks had fired, no `State` had arrived for any of the
// three, and the read published what the ORM happened to hold as though the barrier had
// been crossed. A document whose sync barrier was never reached may hold anything, so
// there is nothing here to call authoritative - the answer is that the question is
// still open. Not a weakened assertion: the old one was wrong.
for (let i = 0; i < 40; i++) await tick();
const snap = obs.getSnapshot();
expect(snap.isSuccess).toBe(false);
expect(snap.isError).toBe(false);
expect(snap.isPending).toBe(true);
expect([getSyncState(a), getSyncState(b), getSyncState(c)]).toEqual([
"timed-out",
"timed-out",
"timed-out",
]);
unsub();
});
@@ -1049,6 +1077,64 @@ describe("useShapeQuery", () => {
unsub();
});
// -- REACHED is not ARRIVED -----------------------------------------------
//
// The barrier asked whether the holder may reach each named document and never whether
// the open it had just awaited actually SYNCED. `open-repo.ts` keeps `"synced"` and
// `"timed-out"` apart precisely so a reactive reader can tell a barrier that was crossed
// from a fallback that fired, and names that reader; this is it.
it("(bb) a named document whose sync barrier was NOT reached is not called empty", async () => {
const ng = makeFake({ pushState: false });
const orm = makeOrm();
inject(ng, orm);
setOpenTimeoutForTests(30); // after `inject`, which restores the 8s default
setCurrentUser("sam");
// HIS OWN, so the capability question passes cleanly and the only thing left standing
// between the read and an answer is the sync barrier - which this broker never
// delivers. The document is NOT empty; the page simply has no authority to say so.
const mine = await createEntityDoc("sam", "protected");
seedEvent(orm, mine, `${mine}:q:one`, "NotEmptyAtAll");
const obs = useShapeQuery(EventShape, { graphs: [mine] });
const unsub = obs.subscribe(() => {});
for (let i = 0; i < 40 && obs.getSnapshot().isPending; i++) await tick();
// The defect this pins: `isSuccess` with `data: []` here is the original lie, in the
// one case the barrier was added to close. Either honest answer is allowed by the
// contract - a longer `isPending`, or `isError` - and what must never appear is a
// successful empty.
const snap = obs.getSnapshot();
expect(snap.isSuccess).toBe(false);
expect(getSyncState(mine)).toBe("timed-out");
unsub();
});
it("(cc) an `ng` with no doc_subscribe has NO barrier to miss, and still answers", async () => {
// The trap on the other side of (bb), and the one this line of work has fallen into
// twice: `"unknown"` means "never requested, OR no `State` semantics on this `ng`".
// Refusing to answer on `"unknown"` would leave every such page pending for ever -
// the whole unit suite, and any consumer whose `ng` lacks the member. There is no
// barrier to have missed where there are none.
const ng = makeFake({ subscribe: false });
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("tess");
const mine = await createEntityDoc("tess", "protected");
seedEvent(orm, mine, `${mine}:q:one`, "Answered");
const obs = useShapeQuery(EventShape, { graphs: [mine] });
const unsub = obs.subscribe(() => {});
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(getSyncState(mine)).toBe("unknown");
const snap = obs.getSnapshot();
expect(snap.isSuccess).toBe(true);
expect(snap.isPending).toBe(false);
expect(titles(snap.data)).toEqual(["Answered"]);
unsub();
});
// -- teardown ------------------------------------------------------------
it("(k) the last unsubscribe closes the shape query", async () => {