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:
@@ -204,6 +204,39 @@ describe("ensureRepoOpen", () => {
|
||||
expect(getSyncState("did:ng:o:abandoned")).toBe("timed-out");
|
||||
});
|
||||
|
||||
// A reset must INVALIDATE the opens it abandons, not merely end them.
|
||||
//
|
||||
// The continuation of an in-flight open records the repo as opened. Settling that open
|
||||
// from the reset (2026-09-21) made the continuation run right after the maps were
|
||||
// cleared, so it wrote a stale `opened` entry into the FRESH registry - and every later
|
||||
// `ensureRepoOpen` for that nuri then short-circuited on it. The repo was never
|
||||
// subscribed in the new session, and a read over it answered as though it had been.
|
||||
// Before that change the same line ran only when the 8s fallback fired, so a re-open
|
||||
// inside that window did re-subscribe; ending the open promptly is right, but it moved
|
||||
// the write into the window where it does damage.
|
||||
it("a reset INVALIDATES the opens it abandons, so the new session subscribes again", async () => {
|
||||
const ng = makeFakeNgWithSubscribe({}, { pushState: false });
|
||||
inject(ng);
|
||||
|
||||
const abandoned = ensureRepoOpen("did:ng:o:reopened");
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
|
||||
|
||||
resetOpenedRepos();
|
||||
await abandoned; // its continuation runs here, on the cleared registry
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
// The new session has subscribed NOTHING yet, so this must open the repo for real.
|
||||
// NOT awaited: this broker never pushes a `State`, so the open runs to its bounded
|
||||
// fallback - and what is under test is that the subscribe HAPPENS, not how it ends.
|
||||
const reopened = ensureRepoOpen("did:ng:o:reopened");
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2);
|
||||
// Released so the fallback's timer does not outlive the test with a live handle.
|
||||
resetOpenedRepos();
|
||||
await reopened;
|
||||
});
|
||||
|
||||
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"]],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user